feat(ai): finalize AI magic edits epic rollout and operations
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run

This commit is contained in:
Codex Agent
2026-02-06 22:41:51 +01:00
parent 36bed12ff9
commit 1d2242fb4d
33 changed files with 2621 additions and 18 deletions

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands;
use App\Models\AiEditRequest;
use App\Models\AiUsageLedger;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class AiEditsPruneCommand extends Command
{
protected $signature = 'ai-edits:prune
{--request-days= : Override AI request retention days}
{--ledger-days= : Override usage ledger retention days}
{--pretend : Report counts without deleting data}';
protected $description = 'Prune stale AI edit requests and usage ledgers based on retention settings.';
public function handle(): int
{
$requestRetentionDays = max(1, (int) ($this->option('request-days') ?: config('ai-editing.retention.request_days', 90)));
$ledgerRetentionDays = max(1, (int) ($this->option('ledger-days') ?: config('ai-editing.retention.usage_ledger_days', 365)));
$pretend = (bool) $this->option('pretend');
$requestCutoff = now()->subDays($requestRetentionDays);
$ledgerCutoff = now()->subDays($ledgerRetentionDays);
$requestQuery = AiEditRequest::query()
->where(function (Builder $query) use ($requestCutoff): void {
$query->where(function (Builder $completedQuery) use ($requestCutoff): void {
$completedQuery->whereNotNull('completed_at')
->where('completed_at', '<=', $requestCutoff);
})->orWhere(function (Builder $expiredQuery): void {
$expiredQuery->whereNotNull('expires_at')
->where('expires_at', '<=', now());
});
});
$ledgerQuery = AiUsageLedger::query()
->where('recorded_at', '<=', $ledgerCutoff);
$requestCount = (clone $requestQuery)->count();
$ledgerCount = (clone $ledgerQuery)->count();
$this->line(sprintf(
'AI prune candidates -> requests: %d (<= %s), ledgers: %d (<= %s)',
$requestCount,
$requestCutoff->toDateString(),
$ledgerCount,
$ledgerCutoff->toDateString()
));
if ($pretend) {
$this->info('Pretend mode enabled. No records were deleted.');
return self::SUCCESS;
}
$deletedRequests = $requestQuery->delete();
$deletedLedgers = $ledgerQuery->delete();
$this->info(sprintf(
'Pruned AI data -> requests: %d, ledgers: %d.',
$deletedRequests,
$deletedLedgers
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Console\Commands;
use App\Jobs\PollAiEditRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class AiEditsRecoverStuckCommand extends Command
{
protected $signature = 'ai-edits:recover-stuck
{--minutes=30 : Minimum age in minutes for queued/processing requests}
{--requeue : Re-dispatch stuck requests back to the queue}
{--fail : Mark stuck requests as failed}';
protected $description = 'Inspect stuck AI edit requests and optionally recover them by requeueing or failing.';
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig)
{
parent::__construct();
}
public function handle(): int
{
$minutes = max(1, (int) $this->option('minutes'));
$shouldRequeue = (bool) $this->option('requeue');
$shouldFail = (bool) $this->option('fail');
if ($shouldRequeue && $shouldFail) {
$this->error('Use either --requeue or --fail, not both.');
return self::FAILURE;
}
$cutoff = now()->subMinutes($minutes);
$requests = AiEditRequest::query()
->with([
'event:id,slug,name',
'providerRuns' => function (HasMany $query): void {
$query->select(['id', 'request_id', 'provider_task_id', 'attempt'])
->orderByDesc('attempt');
},
])
->whereIn('status', [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING])
->where(function (Builder $query) use ($cutoff): void {
$query
->where(function (Builder $queuedQuery) use ($cutoff): void {
$queuedQuery->whereNull('started_at')
->whereNotNull('queued_at')
->where('queued_at', '<=', $cutoff);
})
->orWhere(function (Builder $processingQuery) use ($cutoff): void {
$processingQuery->whereNotNull('started_at')
->where('started_at', '<=', $cutoff);
})
->orWhere(function (Builder $fallbackQuery) use ($cutoff): void {
$fallbackQuery->whereNull('queued_at')
->whereNull('started_at')
->where('updated_at', '<=', $cutoff);
});
})
->orderBy('updated_at')
->get();
if ($requests->isEmpty()) {
$this->info(sprintf('No stuck AI edit requests older than %d minute(s).', $minutes));
return self::SUCCESS;
}
$this->table(
['ID', 'Event', 'Status', 'Queued/Started', 'Latest task'],
$requests->map(function (AiEditRequest $request): array {
$latestTaskId = $this->latestProviderTaskId($request) ?? '-';
$eventLabel = (string) ($request->event?->name ?: $request->event?->slug ?: $request->event_id);
$ageSource = $request->started_at ?: $request->queued_at ?: $request->updated_at;
return [
(string) $request->id,
$eventLabel,
$request->status,
$ageSource?->toIso8601String() ?? '-',
$latestTaskId,
];
})->all()
);
if (! $shouldRequeue && ! $shouldFail) {
$this->info('Dry-run only. Use --requeue to dispatch recovery jobs or --fail to terminate stuck requests.');
return self::SUCCESS;
}
if ($shouldFail) {
$count = $this->markAsFailed($requests);
$this->info(sprintf('Marked %d AI edit request(s) as failed.', $count));
return self::SUCCESS;
}
[$processDispatches, $pollDispatches] = $this->requeueRequests($requests);
$this->info(sprintf(
'Recovered %d stuck AI edit request(s): %d process dispatch(es), %d poll dispatch(es).',
$processDispatches + $pollDispatches,
$processDispatches,
$pollDispatches
));
return self::SUCCESS;
}
/**
* @return array{0:int,1:int}
*/
private function requeueRequests(Collection $requests): array
{
$queueName = $this->runtimeConfig->queueName();
$processDispatches = 0;
$pollDispatches = 0;
foreach ($requests as $request) {
if ($request->status === AiEditRequest::STATUS_QUEUED) {
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
$processDispatches++;
continue;
}
$providerTaskId = $this->latestProviderTaskId($request);
if ($providerTaskId !== null) {
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)->onQueue($queueName);
$pollDispatches++;
continue;
}
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
$processDispatches++;
}
return [$processDispatches, $pollDispatches];
}
private function markAsFailed(Collection $requests): int
{
$updated = 0;
$now = now();
foreach ($requests as $request) {
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'operator_recovery_marked_failed',
'failure_message' => 'Marked as failed by ai-edits:recover-stuck.',
'completed_at' => $now,
])->save();
$updated++;
}
return $updated;
}
private function latestProviderTaskId(AiEditRequest $request): ?string
{
foreach ($request->providerRuns as $run) {
$taskId = trim((string) ($run->provider_task_id ?? ''));
if ($taskId !== '') {
return $taskId;
}
}
return null;
}
}

View File

@@ -54,6 +54,12 @@ class AiStyleResource extends Resource
TextInput::make('name')
->required()
->maxLength(120),
TextInput::make('version')
->numeric()
->default(1)
->disabled()
->dehydrated(false)
->helperText('Auto-increments when core style configuration changes.'),
TextInput::make('category')
->maxLength(50),
TextInput::make('sort')
@@ -107,6 +113,9 @@ class AiStyleResource extends Resource
->copyable(),
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('version')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('provider')
->badge(),
Tables\Columns\TextColumn::make('provider_model')

View File

@@ -8,10 +8,12 @@ use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiBudgetGuardService;
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\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
@@ -27,9 +29,11 @@ class EventPublicAiEditController extends BaseController
private readonly EventJoinTokenService $joinTokenService,
private readonly AiSafetyPolicyService $safetyPolicy,
private readonly AiEditingRuntimeConfig $runtimeConfig,
private readonly AiBudgetGuardService $budgetGuard,
private readonly AiStylingEntitlementService $entitlements,
private readonly EventAiEditingPolicyService $eventPolicy,
private readonly AiStyleAccessService $styleAccess,
private readonly AiAbuseEscalationService $abuseEscalation,
) {}
public function store(GuestAiEditStoreRequest $request, string $token, int $photo): JsonResponse
@@ -95,6 +99,19 @@ class EventPublicAiEditController extends BaseController
);
}
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
if (! $budgetDecision['allowed']) {
return ApiError::response(
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
'Budget limit reached',
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
Response::HTTP_FORBIDDEN,
[
'budget' => $budgetDecision['budget'],
]
);
}
$style = $this->resolveStyleByKey($request->input('style_key'));
if ($request->filled('style_key') && ! $style) {
return ApiError::response(
@@ -126,6 +143,25 @@ class EventPublicAiEditController extends BaseController
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
$sessionId = $this->normalizeOptionalString((string) $request->input('session_id', ''));
$scopeKey = $this->normalizeOptionalString($deviceId ?: $sessionId) ?: 'guest';
$abuseSignal = null;
$safetyReasons = $safetyDecision->reasonCodes;
if ($safetyDecision->blocked) {
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
(int) $event->tenant_id,
(int) $event->id,
$scopeKey
);
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
}
$metadata = (array) $request->input('metadata', []);
if (is_array($abuseSignal)) {
$metadata['abuse'] = $abuseSignal;
}
$metadata['budget'] = $budgetDecision['budget'];
$idempotencyKey = $this->resolveIdempotencyKey(
$request->input('idempotency_key'),
@@ -150,12 +186,12 @@ class EventPublicAiEditController extends BaseController
'input_image_path' => $photoModel->file_path,
'requested_by_device_id' => $deviceId,
'requested_by_session_id' => $sessionId,
'safety_reasons' => $safetyDecision->reasonCodes,
'safety_reasons' => $safetyReasons,
'failure_code' => $safetyDecision->failureCode,
'failure_message' => $safetyDecision->failureMessage,
'queued_at' => now(),
'completed_at' => $safetyDecision->blocked ? now() : null,
'metadata' => $request->input('metadata', []),
'metadata' => $metadata,
];
$editRequest = AiEditRequest::query()->firstOrCreate(
@@ -419,6 +455,7 @@ class EventPublicAiEditController extends BaseController
'id' => $style->id,
'key' => $style->key,
'name' => $style->name,
'version' => $style->version,
'category' => $style->category,
'description' => $style->description,
'provider' => $style->provider,

View File

@@ -7,13 +7,17 @@ use App\Http\Requests\Tenant\AiEditIndexRequest;
use App\Http\Requests\Tenant\AiEditStoreRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiBudgetGuardService;
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\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
@@ -28,9 +32,11 @@ class AiEditController extends Controller
public function __construct(
private readonly AiSafetyPolicyService $safetyPolicy,
private readonly AiEditingRuntimeConfig $runtimeConfig,
private readonly AiBudgetGuardService $budgetGuard,
private readonly AiStylingEntitlementService $entitlements,
private readonly EventAiEditingPolicyService $eventPolicy,
private readonly AiStyleAccessService $styleAccess,
private readonly AiAbuseEscalationService $abuseEscalation,
) {}
public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse
@@ -123,6 +129,8 @@ class AiEditController extends Controller
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$periodStart = now()->startOfMonth();
$periodEnd = now()->endOfMonth();
$baseQuery = AiEditRequest::query()->where('event_id', $event->id);
$statusCounts = (clone $baseQuery)
->select('status', DB::raw('count(*) as aggregate'))
@@ -139,6 +147,32 @@ class AiEditController extends Controller
$lastRequestedAt = (clone $baseQuery)->max('created_at');
$total = array_sum($statusCounts);
$failedTotal = (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0));
$moderationBlockedTotal = (int) ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0);
$usageQuery = AiUsageLedger::query()
->where('tenant_id', $event->tenant_id)
->where('event_id', $event->id)
->where('recorded_at', '>=', $periodStart)
->where('recorded_at', '<=', $periodEnd);
$spendUsd = (float) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->sum('amount_usd') ?: 0.0);
$debitCount = (int) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->count());
$providerRunQuery = AiProviderRun::query()
->whereHas('request', fn ($query) => $query->where('event_id', $event->id))
->where('created_at', '>=', $periodStart)
->where('created_at', '<=', $periodEnd);
$providerRunTotal = (int) (clone $providerRunQuery)->count();
$providerRunFailed = (int) (clone $providerRunQuery)->where('status', AiProviderRun::STATUS_FAILED)->count();
$averageProviderLatencyMs = (int) round((float) ((clone $providerRunQuery)->whereNotNull('duration_ms')->avg('duration_ms') ?: 0.0));
$failureRate = $total > 0 ? ($failedTotal / $total) : 0.0;
$moderationHitRate = $total > 0 ? ($moderationBlockedTotal / $total) : 0.0;
$providerFailureRate = $providerRunTotal > 0 ? ($providerRunFailed / $providerRunTotal) : 0.0;
$failureRateThreshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35);
$latencyWarningThresholdMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000));
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
return response()->json([
'data' => [
@@ -146,8 +180,27 @@ class AiEditController extends Controller
'total' => $total,
'status_counts' => $statusCounts,
'safety_counts' => $safetyCounts,
'failed_total' => (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0)),
'failed_total' => $failedTotal,
'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null,
'usage' => [
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'debit_count' => $debitCount,
'spend_usd' => round($spendUsd, 5),
],
'observability' => [
'failure_rate' => round($failureRate, 5),
'moderation_hit_rate' => round($moderationHitRate, 5),
'provider_runs_total' => $providerRunTotal,
'provider_runs_failed' => $providerRunFailed,
'provider_failure_rate' => round($providerFailureRate, 5),
'avg_provider_latency_ms' => $averageProviderLatencyMs,
'alerts' => [
'failure_rate_threshold_reached' => $failureRate >= $failureRateThreshold && $total >= max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)),
'latency_threshold_reached' => $averageProviderLatencyMs >= $latencyWarningThresholdMs,
],
],
'budget' => $budgetDecision['budget'],
],
]);
}
@@ -214,6 +267,19 @@ class AiEditController extends Controller
);
}
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
if (! $budgetDecision['allowed']) {
return ApiError::response(
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
'Budget limit reached',
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
Response::HTTP_FORBIDDEN,
[
'budget' => $budgetDecision['budget'],
]
);
}
if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) {
return ApiError::response(
'style_not_allowed',
@@ -231,6 +297,25 @@ class AiEditController extends Controller
$providerModel = $request->input('provider_model') ?: $style->provider_model;
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
$requestedByUserId = $request->user()?->id;
$scopeKey = $this->normalizeUserId($requestedByUserId) ?: 'tenant-user';
$abuseSignal = null;
$safetyReasons = $safetyDecision->reasonCodes;
if ($safetyDecision->blocked) {
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
(int) $event->tenant_id,
(int) $event->id,
$scopeKey
);
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
}
$metadata = (array) $request->input('metadata', []);
if (is_array($abuseSignal)) {
$metadata['abuse'] = $abuseSignal;
}
$metadata['budget'] = $budgetDecision['budget'];
$idempotencyKey = $this->resolveIdempotencyKey(
$request->input('idempotency_key'),
@@ -257,12 +342,12 @@ class AiEditController extends Controller
'negative_prompt' => $negativePrompt,
'input_image_path' => $photo->file_path,
'idempotency_key' => $idempotencyKey,
'safety_reasons' => $safetyDecision->reasonCodes,
'safety_reasons' => $safetyReasons,
'failure_code' => $safetyDecision->failureCode,
'failure_message' => $safetyDecision->failureMessage,
'queued_at' => now(),
'completed_at' => $safetyDecision->blocked ? now() : null,
'metadata' => $request->input('metadata', []),
'metadata' => $metadata,
]
);
@@ -439,6 +524,7 @@ class AiEditController extends Controller
'id' => $style->id,
'key' => $style->key,
'name' => $style->name,
'version' => $style->version,
'category' => $style->category,
'description' => $style->description,
'provider' => $style->provider,

View File

@@ -7,7 +7,10 @@ use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiImageProviderManager;
use App\Services\AiEditing\AiObservabilityService;
use App\Services\AiEditing\AiStatusNotificationService;
use App\Services\AiEditing\AiUsageLedgerService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -15,6 +18,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Throwable;
class PollAiEditRequest implements ShouldQueue
{
@@ -43,6 +48,9 @@ class PollAiEditRequest implements ShouldQueue
public function handle(
AiImageProviderManager $providers,
AiSafetyPolicyService $safetyPolicy,
AiAbuseEscalationService $abuseEscalation,
AiObservabilityService $observability,
AiStatusNotificationService $statusNotifications,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
@@ -76,15 +84,37 @@ class PollAiEditRequest implements ShouldQueue
if ($result->status === 'succeeded') {
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
if ($outputDecision->blocked) {
$abuseSignal = $abuseEscalation->recordOutputBlock(
(int) $request->tenant_id,
(int) $request->event_id,
'provider:'.$request->provider
);
$safetyReasons = $outputDecision->reasonCodes;
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
$metadata = (array) ($request->metadata ?? []);
$metadata['abuse'] = $abuseSignal;
$request->forceFill([
'status' => AiEditRequest::STATUS_BLOCKED,
'safety_state' => $outputDecision->state,
'safety_reasons' => $outputDecision->reasonCodes,
'safety_reasons' => $safetyReasons,
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
'failure_message' => $outputDecision->failureMessage,
'metadata' => $metadata,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_BLOCKED,
$run->duration_ms,
true,
'poll'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
@@ -122,6 +152,15 @@ class PollAiEditRequest implements ShouldQueue
'poll_attempt' => $this->pollAttempt,
]);
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_SUCCEEDED,
$run->duration_ms,
false,
'poll'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
@@ -131,8 +170,33 @@ class PollAiEditRequest implements ShouldQueue
self::dispatch($request->id, $this->providerTaskId, $this->pollAttempt + 1)
->delay(now()->addSeconds(20))
->onQueue($runtimeConfig->queueName());
return;
}
$run->forceFill([
'status' => AiProviderRun::STATUS_FAILED,
'finished_at' => now(),
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
'error_message' => sprintf('Polling exhausted after %d attempt(s).', $maxPolls),
])->save();
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'provider_poll_timeout',
'failure_message' => sprintf('Polling timed out after %d attempt(s).', $maxPolls),
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
$run->duration_ms,
false,
'poll'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
@@ -144,5 +208,45 @@ class PollAiEditRequest implements ShouldQueue
'failure_message' => $result->failureMessage,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
$result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
$run->duration_ms,
$result->status === 'blocked',
'poll'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
}
public function failed(Throwable $exception): void
{
$request = AiEditRequest::query()->find($this->requestId);
if (! $request) {
return;
}
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
return;
}
$message = trim($exception->getMessage());
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'queue_job_failed',
'failure_message' => $message !== ''
? Str::limit($message, 500, '')
: 'AI edit polling failed in queue.',
'completed_at' => now(),
])->save();
app(AiObservabilityService::class)->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
null,
false,
'poll_failed_hook'
);
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request->fresh());
}
}

View File

@@ -7,8 +7,11 @@ use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiImageProviderManager;
use App\Services\AiEditing\AiObservabilityService;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\AiStatusNotificationService;
use App\Services\AiEditing\AiUsageLedgerService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -17,6 +20,8 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
class ProcessAiEditRequest implements ShouldQueue
{
@@ -43,6 +48,9 @@ class ProcessAiEditRequest implements ShouldQueue
public function handle(
AiImageProviderManager $providers,
AiSafetyPolicyService $safetyPolicy,
AiAbuseEscalationService $abuseEscalation,
AiObservabilityService $observability,
AiStatusNotificationService $statusNotifications,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
@@ -74,43 +82,121 @@ class ProcessAiEditRequest implements ShouldQueue
$result = $providers->forProvider($request->provider)->submit($request);
$this->finalizeProviderRun($providerRun, $result);
$this->applyProviderResult($request->fresh(['outputs']), $result, $safetyPolicy, $runtimeConfig, $usageLedger);
$this->applyProviderResult(
$request->fresh(['outputs']),
$providerRun,
$result,
$safetyPolicy,
$abuseEscalation,
$observability,
$statusNotifications,
$runtimeConfig,
$usageLedger
);
}
public function failed(Throwable $exception): void
{
$request = AiEditRequest::query()->find($this->requestId);
if (! $request) {
return;
}
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
return;
}
$message = trim($exception->getMessage());
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'queue_job_failed',
'failure_message' => $message !== ''
? Str::limit($message, 500, '')
: 'AI edit processing failed in queue.',
'completed_at' => now(),
])->save();
app(AiObservabilityService::class)->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
null,
false,
'process_failed_hook'
);
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request->fresh());
}
private function finalizeProviderRun(AiProviderRun $run, AiProviderResult $result): void
{
$missingTaskId = $result->status === 'processing'
&& (! is_string($result->providerTaskId) || trim($result->providerTaskId) === '');
$status = $missingTaskId
? AiProviderRun::STATUS_FAILED
: ($result->status === 'succeeded'
? AiProviderRun::STATUS_SUCCEEDED
: ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED));
$run->forceFill([
'provider_task_id' => $result->providerTaskId,
'status' => $result->status === 'succeeded' ? AiProviderRun::STATUS_SUCCEEDED : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED),
'status' => $status,
'http_status' => $result->httpStatus,
'finished_at' => $result->status === 'processing' ? null : now(),
'finished_at' => $status === AiProviderRun::STATUS_RUNNING ? 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,
'error_message' => $missingTaskId
? 'Provider returned processing state without task identifier.'
: $result->failureMessage,
])->save();
}
private function applyProviderResult(
AiEditRequest $request,
AiProviderRun $providerRun,
AiProviderResult $result,
AiSafetyPolicyService $safetyPolicy,
AiAbuseEscalationService $abuseEscalation,
AiObservabilityService $observability,
AiStatusNotificationService $statusNotifications,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
if ($result->status === 'succeeded') {
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
if ($outputDecision->blocked) {
$abuseSignal = $abuseEscalation->recordOutputBlock(
(int) $request->tenant_id,
(int) $request->event_id,
'provider:'.$request->provider
);
$safetyReasons = $outputDecision->reasonCodes;
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
$metadata = (array) ($request->metadata ?? []);
$metadata['abuse'] = $abuseSignal;
$request->forceFill([
'status' => AiEditRequest::STATUS_BLOCKED,
'safety_state' => $outputDecision->state,
'safety_reasons' => $outputDecision->reasonCodes,
'safety_reasons' => $safetyReasons,
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
'failure_message' => $outputDecision->failureMessage,
'metadata' => $metadata,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_BLOCKED,
$providerRun->duration_ms,
true,
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
@@ -149,21 +235,49 @@ class ProcessAiEditRequest implements ShouldQueue
'source' => 'process_job',
]);
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_SUCCEEDED,
$providerRun->duration_ms,
false,
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
if ($result->status === 'processing') {
$providerTaskId = trim((string) ($result->providerTaskId ?? ''));
if ($providerTaskId === '') {
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'provider_task_id_missing',
'failure_message' => 'Provider returned processing state without a task identifier.',
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
$providerRun->duration_ms,
false,
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
$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());
}
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)
->delay(now()->addSeconds(20))
->onQueue($runtimeConfig->queueName());
return;
}
@@ -176,5 +290,14 @@ class ProcessAiEditRequest implements ShouldQueue
'failure_message' => $result->failureMessage,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
$result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
$providerRun->duration_ms,
$result->status === 'blocked',
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
}
}

View File

@@ -10,9 +10,37 @@ class AiStyle extends Model
{
use HasFactory;
protected static function booted(): void
{
static::creating(function (self $style): void {
if ((int) ($style->version ?? 0) < 1) {
$style->version = 1;
}
});
static::updating(function (self $style): void {
$versionedFields = [
'prompt_template',
'negative_prompt_template',
'provider',
'provider_model',
'metadata',
'is_premium',
'requires_source_image',
];
if ($style->isDirty($versionedFields)) {
$current = max(1, (int) ($style->getOriginal('version') ?? 1));
$requested = max(1, (int) ($style->version ?? 0));
$style->version = max($requested, $current + 1);
}
});
}
protected $fillable = [
'key',
'name',
'version',
'category',
'description',
'prompt_template',
@@ -32,6 +60,7 @@ class AiStyle extends Model
'requires_source_image' => 'boolean',
'is_premium' => 'boolean',
'is_active' => 'boolean',
'version' => 'integer',
'sort' => 'integer',
'metadata' => 'array',
];

View File

@@ -176,10 +176,12 @@ class AppServiceProvider extends ServiceProvider
$deviceId = trim((string) $request->header('X-Device-Id', ''));
$scope = $deviceId !== '' ? 'device:'.$deviceId : 'ip:'.($request->ip() ?? 'unknown');
$key = 'ai-edit-guest-submit:'.$token.':'.$scope;
$eventKey = 'ai-edit-guest-submit:event:'.$token;
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),
Limit::perMinute(max(1, (int) config('ai-editing.abuse.guest_submit_per_event_per_minute', 40)))->by($eventKey),
];
});
@@ -195,11 +197,14 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('ai-edit-tenant-submit', function (Request $request) {
$tenantId = (string) ($request->attributes->get('tenant_id') ?? 'tenant');
$userId = (string) ($request->user()?->id ?? 'guest');
$eventSlug = (string) ($request->route('eventSlug') ?? 'event');
$key = 'ai-edit-tenant-submit:'.$tenantId.':'.$userId;
$eventKey = 'ai-edit-tenant-submit:event:'.$tenantId.':'.$eventSlug;
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),
Limit::perMinute(max(1, (int) config('ai-editing.abuse.tenant_submit_per_event_per_minute', 120)))->by($eventKey),
];
});

View File

@@ -0,0 +1,221 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\TenantNotificationReceipt;
use App\Services\Packages\TenantNotificationLogger;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AiBudgetGuardService
{
/**
* @return array{
* allowed: bool,
* reason_code: ?string,
* message: ?string,
* budget: array{
* period_start: string,
* period_end: string,
* current_spend_usd: float,
* soft_cap_usd: ?float,
* hard_cap_usd: ?float,
* soft_reached: bool,
* hard_reached: bool,
* hard_stop_enabled: bool,
* override_active: bool
* }
* }
*/
public function evaluateForEvent(Event $event): array
{
$now = now();
$periodStart = $now->copy()->startOfMonth();
$periodEnd = $now->copy()->endOfMonth();
$tenantSettings = (array) ($event->tenant?->settings ?? []);
$softCap = $this->resolveCap(
Arr::get($tenantSettings, 'ai_editing.budget.soft_cap_usd'),
config('ai-editing.billing.budget.soft_cap_usd')
);
$hardCap = $this->resolveCap(
Arr::get($tenantSettings, 'ai_editing.budget.hard_cap_usd'),
config('ai-editing.billing.budget.hard_cap_usd')
);
if ($softCap !== null && $hardCap !== null && $softCap > $hardCap) {
[$softCap, $hardCap] = [$hardCap, $softCap];
}
$spendUsd = (float) (AiUsageLedger::query()
->where('tenant_id', $event->tenant_id)
->where('recorded_at', '>=', $periodStart)
->where('recorded_at', '<=', $periodEnd)
->where('entry_type', AiUsageLedger::TYPE_DEBIT)
->sum('amount_usd') ?: 0.0);
$softReached = $softCap !== null && $spendUsd >= $softCap;
$hardReached = $hardCap !== null && $spendUsd >= $hardCap;
$hardStopEnabled = (bool) config('ai-editing.billing.budget.hard_stop_enabled', true);
$overrideUntil = Arr::get($tenantSettings, 'ai_editing.budget.override_until');
$overrideActive = $this->isOverrideActive($overrideUntil, $now);
if ($softReached) {
$this->emitBudgetAlert($event, 'soft_cap_reached', [
'spend_usd' => $spendUsd,
'soft_cap_usd' => $softCap,
'hard_cap_usd' => $hardCap,
]);
}
if ($hardReached) {
$this->emitBudgetAlert($event, 'hard_cap_reached', [
'spend_usd' => $spendUsd,
'soft_cap_usd' => $softCap,
'hard_cap_usd' => $hardCap,
'hard_stop_enabled' => $hardStopEnabled,
'override_active' => $overrideActive,
]);
}
$allowed = ! ($hardReached && $hardStopEnabled && ! $overrideActive);
return [
'allowed' => $allowed,
'reason_code' => $allowed ? null : 'budget_hard_cap_reached',
'message' => $allowed ? null : 'The AI editing budget for this billing period has been exhausted.',
'budget' => [
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'current_spend_usd' => round($spendUsd, 5),
'soft_cap_usd' => $softCap,
'hard_cap_usd' => $hardCap,
'soft_reached' => $softReached,
'hard_reached' => $hardReached,
'hard_stop_enabled' => $hardStopEnabled,
'override_active' => $overrideActive,
],
];
}
private function resolveCap(mixed $tenantValue, mixed $defaultValue): ?float
{
if (is_numeric($tenantValue)) {
$resolved = (float) $tenantValue;
return $resolved >= 0 ? $resolved : null;
}
if (is_numeric($defaultValue)) {
$resolved = (float) $defaultValue;
return $resolved >= 0 ? $resolved : null;
}
return null;
}
private function isOverrideActive(mixed $overrideUntil, Carbon $now): bool
{
if (! is_string($overrideUntil) || trim($overrideUntil) === '') {
return false;
}
try {
$deadline = Carbon::parse($overrideUntil);
} catch (\Throwable) {
return false;
}
return $deadline->isFuture();
}
/**
* @param array<string, mixed> $context
*/
private function emitBudgetAlert(Event $event, string $type, array $context): void
{
$cooldownMinutes = max(1, (int) config('ai-editing.billing.budget.alert_cooldown_minutes', 30));
$cacheKey = sprintf('ai-editing:budget-alert:%s:tenant:%d:event:%d', $type, $event->tenant_id, $event->id);
if (! Cache::add($cacheKey, 1, now()->addMinutes($cooldownMinutes))) {
return;
}
Log::warning('AI budget threshold reached', array_merge($context, [
'type' => $type,
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
]));
$this->notifyTenant($event, $type, $context);
}
/**
* @param array<string, mixed> $context
*/
private function notifyTenant(Event $event, string $type, array $context): void
{
/** @var Tenant|null $tenant */
$tenant = $event->tenant()->with('user')->first();
if (! $tenant) {
return;
}
$notificationType = $type === 'hard_cap_reached'
? 'ai_budget_hard_cap'
: 'ai_budget_soft_cap';
$log = app(TenantNotificationLogger::class)->log($tenant, [
'type' => $notificationType,
'channel' => 'system',
'status' => 'sent',
'sent_at' => now(),
'context' => [
'scope' => 'ai',
'threshold' => $type,
'event_id' => (int) $event->id,
'event_slug' => (string) $event->slug,
'event_name' => $this->resolveEventName($event->name),
'spend_usd' => round((float) ($context['spend_usd'] ?? 0), 5),
'soft_cap_usd' => is_numeric($context['soft_cap_usd'] ?? null) ? (float) $context['soft_cap_usd'] : null,
'hard_cap_usd' => is_numeric($context['hard_cap_usd'] ?? null) ? (float) $context['hard_cap_usd'] : null,
'hard_stop_enabled' => (bool) ($context['hard_stop_enabled'] ?? config('ai-editing.billing.budget.hard_stop_enabled', true)),
'override_active' => (bool) ($context['override_active'] ?? false),
],
]);
$userId = (int) ($tenant->user_id ?? 0);
if ($userId > 0) {
TenantNotificationReceipt::query()->create([
'tenant_id' => (int) $tenant->id,
'notification_log_id' => (int) $log->id,
'user_id' => $userId,
'status' => 'delivered',
]);
}
}
private function resolveEventName(mixed $name): ?string
{
if (is_string($name) && trim($name) !== '') {
return trim($name);
}
if (is_array($name)) {
foreach ($name as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}
}
return null;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiEditRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AiObservabilityService
{
public function recordTerminalOutcome(
AiEditRequest $request,
string $status,
?int $durationMs = null,
bool $moderationBlocked = false,
string $stage = 'job'
): void {
$bucket = now()->format('YmdH');
$prefix = sprintf('ai-editing:obs:tenant:%d:event:%d:hour:%s', $request->tenant_id, $request->event_id, $bucket);
Cache::add($prefix.':total', 0, now()->addHours(8));
$total = (int) Cache::increment($prefix.':total');
if ($status === AiEditRequest::STATUS_SUCCEEDED) {
Cache::add($prefix.':succeeded', 0, now()->addHours(8));
Cache::increment($prefix.':succeeded');
} elseif ($status === AiEditRequest::STATUS_BLOCKED) {
Cache::add($prefix.':blocked', 0, now()->addHours(8));
Cache::increment($prefix.':blocked');
} elseif ($status === AiEditRequest::STATUS_FAILED) {
Cache::add($prefix.':failed', 0, now()->addHours(8));
Cache::increment($prefix.':failed');
}
if ($moderationBlocked) {
Cache::add($prefix.':moderation_blocked', 0, now()->addHours(8));
Cache::increment($prefix.':moderation_blocked');
}
if (is_int($durationMs) && $durationMs > 0) {
Cache::add($prefix.':duration_total_ms', 0, now()->addHours(8));
Cache::increment($prefix.':duration_total_ms', $durationMs);
$latencyWarningMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000));
if ($durationMs >= $latencyWarningMs) {
Log::warning('AI provider latency warning', [
'tenant_id' => $request->tenant_id,
'event_id' => $request->event_id,
'request_id' => $request->id,
'duration_ms' => $durationMs,
'threshold_ms' => $latencyWarningMs,
'stage' => $stage,
]);
}
}
$this->checkFailureRateAlert($request, $prefix, $total, $stage);
}
private function checkFailureRateAlert(AiEditRequest $request, string $prefix, int $total, string $stage): void
{
$minSamples = max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10));
if ($total < $minSamples) {
return;
}
$failed = (int) (Cache::get($prefix.':failed', 0) ?: 0);
$threshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35);
$failureRate = $total > 0 ? ($failed / $total) : 0.0;
if ($failureRate < $threshold) {
return;
}
$cooldownKey = $prefix.':failure_rate_alert';
if (! Cache::add($cooldownKey, 1, now()->addMinutes(30))) {
return;
}
Log::warning('AI failure-rate alert threshold reached', [
'tenant_id' => $request->tenant_id,
'event_id' => $request->event_id,
'failure_rate' => round($failureRate, 5),
'failed' => $failed,
'total' => $total,
'threshold' => $threshold,
'stage' => $stage,
]);
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Services\AiEditing;
use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationType;
use App\Models\AiEditRequest;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\TenantNotificationReceipt;
use App\Services\GuestNotificationService;
use App\Services\Packages\TenantNotificationLogger;
use Illuminate\Support\Facades\Cache;
class AiStatusNotificationService
{
public function __construct(
private readonly GuestNotificationService $guestNotifications,
private readonly TenantNotificationLogger $tenantNotificationLogger,
) {}
public function notifyTerminalOutcome(AiEditRequest $request): void
{
$status = (string) $request->status;
if (! in_array($status, [
AiEditRequest::STATUS_SUCCEEDED,
AiEditRequest::STATUS_FAILED,
AiEditRequest::STATUS_BLOCKED,
], true)) {
return;
}
if (! $this->claimLock((int) $request->id, $status)) {
return;
}
$event = Event::query()
->with('tenant.user')
->find((int) $request->event_id);
if (! $event || ! $event->tenant) {
return;
}
$request->loadMissing('style');
$this->notifyTenant($event->tenant, $event, $request, $status);
if ((int) ($request->requested_by_user_id ?? 0) === 0) {
$this->notifyGuest($event, $request, $status);
}
}
private function claimLock(int $requestId, string $status): bool
{
$key = sprintf('ai-editing:terminal-notification:request:%d:status:%s', $requestId, $status);
return Cache::add($key, 1, now()->addDays(7));
}
private function notifyGuest(Event $event, AiEditRequest $request, string $status): void
{
[$title, $body] = match ($status) {
AiEditRequest::STATUS_SUCCEEDED => [
'Dein AI-Magic-Edit ist fertig ✨',
'Dein bearbeitetes Foto ist jetzt verfügbar.',
],
AiEditRequest::STATUS_BLOCKED => [
'AI-Magic-Edit wurde blockiert',
'Die Bearbeitung wurde durch die Sicherheitsregeln gestoppt.',
],
default => [
'AI-Magic-Edit fehlgeschlagen',
'Die Bearbeitung konnte nicht abgeschlossen werden. Bitte erneut versuchen.',
],
};
$options = [
'payload' => [
'photo_id' => (int) $request->photo_id,
'count' => 1,
],
'priority' => $status === AiEditRequest::STATUS_SUCCEEDED ? 2 : 3,
'expires_at' => now()->addHours(6),
'audience_scope' => GuestNotificationAudience::ALL,
];
$deviceId = trim((string) ($request->requested_by_device_id ?? ''));
if ($deviceId !== '') {
$options['audience_scope'] = GuestNotificationAudience::GUEST;
$options['target_identifier'] = $deviceId;
}
$this->guestNotifications->createNotification(
$event,
GuestNotificationType::UPLOAD_ALERT,
$title,
$body,
$options
);
}
private function notifyTenant(Tenant $tenant, Event $event, AiEditRequest $request, string $status): void
{
$type = match ($status) {
AiEditRequest::STATUS_SUCCEEDED => 'ai_edit_succeeded',
AiEditRequest::STATUS_BLOCKED => 'ai_edit_blocked',
default => 'ai_edit_failed',
};
$log = $this->tenantNotificationLogger->log($tenant, [
'type' => $type,
'channel' => 'system',
'status' => 'sent',
'sent_at' => now(),
'context' => [
'scope' => 'ai',
'status' => $status,
'event_id' => (int) $event->id,
'event_slug' => (string) $event->slug,
'event_name' => $this->resolveEventName($event->name),
'request_id' => (int) $request->id,
'photo_id' => (int) $request->photo_id,
'style_key' => $request->style?->key,
'style_name' => $request->style?->name,
'failure_code' => $request->failure_code,
],
]);
$this->createReceipt($tenant, (int) $log->id);
}
private function createReceipt(Tenant $tenant, int $logId): void
{
$userId = (int) ($tenant->user_id ?? 0);
if ($userId <= 0) {
return;
}
TenantNotificationReceipt::query()->create([
'tenant_id' => (int) $tenant->id,
'notification_log_id' => $logId,
'user_id' => $userId,
'status' => 'delivered',
]);
}
private function resolveEventName(mixed $name): ?string
{
if (is_string($name) && trim($name) !== '') {
return trim($name);
}
if (is_array($name)) {
foreach ($name as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}
}
return null;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Services\AiEditing\Safety;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AiAbuseEscalationService
{
public const REASON_CODE = 'abuse_escalation_threshold_reached';
/**
* @return array{type:string,count:int,threshold:int,escalated:bool,reason_code:?string}
*/
public function recordPromptBlock(int $tenantId, int $eventId, string $scope): array
{
return $this->record('prompt_block', $tenantId, $eventId, $scope);
}
/**
* @return array{type:string,count:int,threshold:int,escalated:bool,reason_code:?string}
*/
public function recordOutputBlock(int $tenantId, int $eventId, string $scope): array
{
return $this->record('output_block', $tenantId, $eventId, $scope);
}
/**
* @return array{type:string,count:int,threshold:int,escalated:bool,reason_code:?string}
*/
private function record(string $type, int $tenantId, int $eventId, string $scope): array
{
$threshold = max(1, (int) config('ai-editing.abuse.escalation_threshold_per_hour', 25));
$cooldownMinutes = max(1, (int) config('ai-editing.abuse.escalation_cooldown_minutes', 30));
$bucket = now()->format('YmdH');
$counterKey = sprintf('ai-editing:abuse:%s:tenant:%d:event:%d:hour:%s', $type, $tenantId, $eventId, $bucket);
Cache::add($counterKey, 0, now()->addHours(2));
$count = (int) Cache::increment($counterKey);
$escalated = $count >= $threshold;
if ($escalated) {
$cooldownKey = sprintf('ai-editing:abuse:%s:tenant:%d:event:%d:cooldown', $type, $tenantId, $eventId);
if (Cache::add($cooldownKey, 1, now()->addMinutes($cooldownMinutes))) {
Log::warning('AI abuse escalation threshold reached', [
'tenant_id' => $tenantId,
'event_id' => $eventId,
'type' => $type,
'count' => $count,
'threshold' => $threshold,
'scope_hash' => hash('sha256', $scope),
]);
}
}
return [
'type' => $type,
'count' => $count,
'threshold' => $threshold,
'escalated' => $escalated,
'reason_code' => $escalated ? self::REASON_CODE : null,
];
}
}

View File

@@ -30,6 +30,8 @@ return Application::configure(basePath: dirname(__DIR__))
\App\Console\Commands\MonitorStorageCommand::class,
\App\Console\Commands\DispatchStorageArchiveCommand::class,
\App\Console\Commands\CheckUploadQueuesCommand::class,
\App\Console\Commands\AiEditsRecoverStuckCommand::class,
\App\Console\Commands\AiEditsPruneCommand::class,
\App\Console\Commands\PurgeExpiredDataExports::class,
\App\Console\Commands\ProcessTenantRetention::class,
\App\Console\Commands\SendGuestFeedbackReminders::class,
@@ -65,6 +67,10 @@ return Application::configure(basePath: dirname(__DIR__))
->dailyAt('01:00')
->withoutOverlapping()
->onFailure($onFailure('storage:archive-pending'));
$schedule->command('ai-edits:prune')
->dailyAt('02:30')
->withoutOverlapping()
->onFailure($onFailure('ai-edits:prune'));
$schedule->command('photobooth:cleanup-expired')
->hourly()
->withoutOverlapping()

View File

@@ -24,10 +24,14 @@ return [
'abuse' => [
'guest_submit_per_minute' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_MINUTE', 8),
'guest_submit_per_hour' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_HOUR', 40),
'guest_submit_per_event_per_minute' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_EVENT_PER_MINUTE', 40),
'guest_status_per_minute' => (int) env('AI_EDITING_GUEST_STATUS_PER_MINUTE', 60),
'tenant_submit_per_minute' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_MINUTE', 30),
'tenant_submit_per_hour' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_HOUR', 240),
'tenant_submit_per_event_per_minute' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_EVENT_PER_MINUTE', 120),
'tenant_status_per_minute' => (int) env('AI_EDITING_TENANT_STATUS_PER_MINUTE', 120),
'escalation_threshold_per_hour' => (int) env('AI_EDITING_ESCALATION_THRESHOLD_PER_HOUR', 25),
'escalation_cooldown_minutes' => (int) env('AI_EDITING_ESCALATION_COOLDOWN_MINUTES', 30),
],
'queue' => [
@@ -38,6 +42,17 @@ return [
'billing' => [
'default_unit_cost_usd' => (float) env('AI_EDITING_DEFAULT_UNIT_COST_USD', 0.01),
'budget' => [
'soft_cap_usd' => env('AI_EDITING_BUDGET_SOFT_CAP_USD'),
'hard_cap_usd' => env('AI_EDITING_BUDGET_HARD_CAP_USD'),
'hard_stop_enabled' => (bool) env('AI_EDITING_BUDGET_HARD_STOP_ENABLED', true),
'alert_cooldown_minutes' => (int) env('AI_EDITING_BUDGET_ALERT_COOLDOWN_MINUTES', 30),
],
],
'retention' => [
'request_days' => (int) env('AI_EDITING_REQUEST_RETENTION_DAYS', 90),
'usage_ledger_days' => (int) env('AI_EDITING_USAGE_LEDGER_RETENTION_DAYS', 365),
],
'providers' => [
@@ -45,4 +60,10 @@ return [
'mode' => env('AI_EDITING_RUNWARE_MODE', 'live'),
],
],
'observability' => [
'failure_rate_alert_threshold' => (float) env('AI_EDITING_FAILURE_RATE_ALERT_THRESHOLD', 0.35),
'failure_rate_min_samples' => (int) env('AI_EDITING_FAILURE_RATE_MIN_SAMPLES', 10),
'latency_warning_ms' => (int) env('AI_EDITING_LATENCY_WARNING_MS', 15000),
],
];

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ai_styles', function (Blueprint $table) {
$table->unsignedInteger('version')->default(1)->after('name');
$table->index(['is_active', 'version']);
});
}
public function down(): void
{
Schema::table('ai_styles', function (Blueprint $table) {
$table->dropIndex(['is_active', 'version']);
$table->dropColumn('version');
});
}
};

View File

@@ -337,6 +337,27 @@
"title": "Gäste-Nutzung Warnung",
"body": "{{event}} liegt bei {{used}} / {{limit}} Gästen."
},
"aiEditSucceeded": {
"title": "AI-Edit abgeschlossen",
"body": "{{event}} hat ein fertiges AI-Edit."
},
"aiEditFailed": {
"title": "AI-Edit fehlgeschlagen",
"body": "{{event}} konnte ein AI-Edit nicht abschließen. Grund: {{reason}}.",
"reasonUnknown": "unbekannt"
},
"aiEditBlocked": {
"title": "AI-Edit blockiert",
"body": "Ein AI-Edit für {{event}} wurde durch Sicherheitsprüfungen blockiert."
},
"aiBudgetSoftCap": {
"title": "AI-Budget Warnung",
"body": "{{event}} hat {{spend}} USD von {{cap}} USD AI-Budget erreicht."
},
"aiBudgetHardCap": {
"title": "AI-Budget ausgeschöpft",
"body": "{{event}} hat das harte AI-Budget-Limit von {{cap}} USD erreicht."
},
"generic": {
"body": "Benachrichtigung über {{channel}}."
},
@@ -354,6 +375,7 @@
"gallery": "Galerie",
"events": "Events",
"package": "Paket",
"ai": "AI",
"general": "Allgemein"
},
"markAllRead": "Alle als gelesen markieren",

View File

@@ -333,6 +333,27 @@
"title": "Guest usage warning",
"body": "{{event}} is at {{used}} / {{limit}} guests."
},
"aiEditSucceeded": {
"title": "AI edit completed",
"body": "{{event}} has a completed AI edit ready."
},
"aiEditFailed": {
"title": "AI edit failed",
"body": "{{event}} could not finish an AI edit. Reason: {{reason}}.",
"reasonUnknown": "unknown"
},
"aiEditBlocked": {
"title": "AI edit blocked",
"body": "{{event}} AI edit was blocked by safety checks."
},
"aiBudgetSoftCap": {
"title": "AI budget warning",
"body": "{{event}} reached {{spend}} USD of {{cap}} USD AI budget."
},
"aiBudgetHardCap": {
"title": "AI budget exhausted",
"body": "{{event}} reached the hard AI budget cap of {{cap}} USD."
},
"generic": {
"body": "Notification sent via {{channel}}."
},
@@ -350,6 +371,7 @@
"gallery": "Gallery",
"events": "Events",
"package": "Package",
"ai": "AI",
"general": "General"
},
"markAllRead": "Mark all read",

View File

@@ -144,6 +144,10 @@ function formatLog(
const used = typeof ctx.used === 'number' ? ctx.used : null;
const remaining = typeof ctx.remaining === 'number' ? ctx.remaining : null;
const days = typeof ctx.day === 'number' ? ctx.day : null;
const spendUsd = typeof ctx.spend_usd === 'number' ? ctx.spend_usd : null;
const softCapUsd = typeof ctx.soft_cap_usd === 'number' ? ctx.soft_cap_usd : null;
const hardCapUsd = typeof ctx.hard_cap_usd === 'number' ? ctx.hard_cap_usd : null;
const failureCode = typeof ctx.failure_code === 'string' ? ctx.failure_code : null;
const ctxEventId = ctx.event_id ?? ctx.eventId;
const eventId = typeof ctxEventId === 'string' ? Number(ctxEventId) : (typeof ctxEventId === 'number' ? ctxEventId : null);
const name = eventName ?? t('mobileNotifications.unknownEvent', 'Event');
@@ -165,6 +169,12 @@ function formatLog(
case 'package_expiring':
case 'package_expired':
return 'package';
case 'ai_edit_succeeded':
case 'ai_edit_failed':
case 'ai_edit_blocked':
case 'ai_budget_soft_cap':
case 'ai_budget_hard_cap':
return 'ai';
default:
return 'general';
}
@@ -276,6 +286,80 @@ function formatLog(
is_read: isRead,
scope,
};
case 'ai_edit_succeeded':
return {
id: String(log.id),
title: t('notificationLogs.aiEditSucceeded.title', 'AI edit completed'),
body: t('notificationLogs.aiEditSucceeded.body', '{{event}} has a completed AI edit ready.', {
event: name,
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
case 'ai_edit_failed':
return {
id: String(log.id),
title: t('notificationLogs.aiEditFailed.title', 'AI edit failed'),
body: t('notificationLogs.aiEditFailed.body', '{{event}} could not finish an AI edit. Reason: {{reason}}.', {
event: name,
reason: failureCode ?? t('notificationLogs.aiEditFailed.reasonUnknown', 'unknown'),
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
case 'ai_edit_blocked':
return {
id: String(log.id),
title: t('notificationLogs.aiEditBlocked.title', 'AI edit blocked'),
body: t('notificationLogs.aiEditBlocked.body', '{{event}} AI edit was blocked by safety checks.', {
event: name,
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
case 'ai_budget_soft_cap':
return {
id: String(log.id),
title: t('notificationLogs.aiBudgetSoftCap.title', 'AI budget warning'),
body: t('notificationLogs.aiBudgetSoftCap.body', '{{event}} reached {{spend}} USD of {{cap}} USD AI budget.', {
event: name,
spend: spendUsd ?? '—',
cap: softCapUsd ?? '—',
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
case 'ai_budget_hard_cap':
return {
id: String(log.id),
title: t('notificationLogs.aiBudgetHardCap.title', 'AI budget exhausted'),
body: t('notificationLogs.aiBudgetHardCap.body', '{{event}} reached the hard AI budget cap of {{cap}} USD.', {
event: name,
cap: hardCapUsd ?? '—',
}),
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
default:
return {
id: String(log.id),
@@ -379,6 +463,16 @@ export default function MobileNotificationsPage() {
};
}, [reload]);
React.useEffect(() => {
const interval = window.setInterval(() => {
void reload();
}, 90_000);
return () => {
window.clearInterval(interval);
};
}, [reload]);
React.useEffect(() => {
(async () => {
try {
@@ -588,6 +682,7 @@ export default function MobileNotificationsPage() {
{ key: 'gallery', label: t('notificationLogs.scope.gallery', 'Gallery') },
{ key: 'events', label: t('notificationLogs.scope.events', 'Events') },
{ key: 'package', label: t('notificationLogs.scope.package', 'Package') },
{ key: 'ai', label: t('notificationLogs.scope.ai', 'AI') },
{ key: 'general', label: t('notificationLogs.scope.general', 'General') },
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => {
const isActive = (scopeParam ?? 'all') === filter.key;

View File

@@ -10,6 +10,8 @@ export function useNotificationsBadge() {
const { data: count = 0 } = useQuery<number>({
queryKey: ['mobile', 'notifications', 'badge', 'tenant'],
staleTime: 60_000,
refetchInterval: 90_000,
refetchIntervalInBackground: true,
queryFn: async () => {
const logs = await listNotificationLogs({ perPage: 1 });
const meta: any = logs.meta ?? {};

View File

@@ -30,4 +30,14 @@ describe('groupNotificationsByScope', () => {
const grouped = groupNotificationsByScope(items);
expect(grouped.map((group) => group.scope)).toEqual(['photos', 'events', 'general']);
});
it('places ai scope before general', () => {
const items: Item[] = [
{ id: '1', scope: 'general', is_read: true },
{ id: '2', scope: 'ai', is_read: true },
];
const grouped = groupNotificationsByScope(items);
expect(grouped.map((group) => group.scope)).toEqual(['ai', 'general']);
});
});

View File

@@ -1,4 +1,4 @@
export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'ai' | 'general';
export type ScopedNotification = {
scope: NotificationScope;
@@ -17,6 +17,7 @@ const SCOPE_ORDER: NotificationScope[] = [
'gallery',
'events',
'package',
'ai',
'general',
];

View File

@@ -29,6 +29,7 @@ class AiEditingDataModelTest extends TestCase
'key',
'provider',
'provider_model',
'version',
'requires_source_image',
'is_premium',
] as $column) {

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Event;
use App\Models\AiEditingSetting;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
@@ -302,6 +303,7 @@ class EventAiEditControllerTest extends TestCase
$response->assertOk()
->assertJsonPath('data.0.id', $allowed->id)
->assertJsonPath('data.0.version', 1)
->assertJsonCount(1, 'data')
->assertJsonPath('meta.required_feature', 'ai_styling')
->assertJsonPath('meta.allow_custom_prompt', false)
@@ -594,6 +596,129 @@ class EventAiEditControllerTest extends TestCase
$second->assertStatus(429);
}
public function test_guest_submit_endpoint_enforces_event_scope_rate_limit_across_devices(): void
{
config([
'ai-editing.abuse.guest_submit_per_minute' => 10,
'ai-editing.abuse.guest_submit_per_hour' => 100,
'ai-editing.abuse.guest_submit_per_event_per_minute' => 1,
]);
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-event-rate-limit'])
->getAttribute('plain_token');
$first = $this->withHeaders(['X-Device-Id' => 'guest-device-event-limit-a'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'First request.',
'idempotency_key' => 'guest-event-rate-limit-1',
]);
$first->assertCreated();
$second = $this->withHeaders(['X-Device-Id' => 'guest-device-event-limit-b'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Second request.',
'idempotency_key' => 'guest-event-rate-limit-2',
]);
$second->assertStatus(429);
}
public function test_guest_cannot_create_ai_edit_when_hard_budget_cap_is_reached(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$tenantSettings = (array) ($event->tenant->settings ?? []);
data_set($tenantSettings, 'ai_editing.budget.hard_cap_usd', 0.01);
$event->tenant->update(['settings' => $tenantSettings]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
AiUsageLedger::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 0.02,
'amount_usd' => 0.02,
'currency' => 'USD',
'recorded_at' => now(),
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-budget-hard-cap'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-budget-cap'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Stylize image.',
'idempotency_key' => 'guest-budget-cap-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'budget_hard_cap_reached')
->assertJsonPath('error.meta.budget.hard_reached', true);
}
public function test_guest_prompt_block_records_abuse_metadata_when_escalation_threshold_is_reached(): void
{
config([
'ai-editing.abuse.escalation_threshold_per_hour' => 1,
'ai-editing.abuse.escalation_cooldown_minutes' => 1,
]);
AiEditingSetting::flushCache();
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
['blocked_terms' => ['explicit']]
));
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-escalation'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-escalation'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Create an explicit style image.',
'idempotency_key' => 'guest-escalation-1',
]);
$response->assertCreated()
->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED)
->assertJsonFragment(['abuse_escalation_threshold_reached']);
$requestId = (int) $response->json('data.id');
$editRequest = AiEditRequest::query()->find($requestId);
$this->assertNotNull($editRequest);
$this->assertIsArray($editRequest?->metadata);
$this->assertSame('prompt_block', $editRequest?->metadata['abuse']['type'] ?? null);
$this->assertTrue((bool) ($editRequest?->metadata['abuse']['escalated'] ?? false));
}
public function test_guest_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void
{
$event = Event::factory()->create([

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Tenant;
use App\Models\AiEditingSetting;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
@@ -305,6 +306,7 @@ class TenantAiEditControllerTest extends TenantTestCase
$response->assertOk()
->assertJsonPath('data.0.id', $active->id)
->assertJsonPath('data.0.version', 1)
->assertJsonCount(1, 'data')
->assertJsonPath('meta.required_feature', 'ai_styling')
->assertJsonPath('meta.event_enabled', false)
@@ -540,6 +542,148 @@ class TenantAiEditControllerTest extends TenantTestCase
$second->assertStatus(429);
}
public function test_tenant_submit_endpoint_enforces_event_scope_rate_limit(): void
{
config([
'ai-editing.abuse.tenant_submit_per_minute' => 10,
'ai-editing.abuse.tenant_submit_per_hour' => 100,
'ai-editing.abuse.tenant_submit_per_event_per_minute' => 1,
]);
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'tenant-event-rate-limit-style',
'name' => 'Tenant Event Rate Limit',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$first = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'First request.',
'idempotency_key' => 'tenant-event-rate-limit-1',
]);
$first->assertCreated();
$second = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Second request.',
'idempotency_key' => 'tenant-event-rate-limit-2',
]);
$second->assertStatus(429);
}
public function test_tenant_cannot_create_ai_edit_when_hard_budget_cap_is_reached(): void
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$tenantSettings = (array) ($this->tenant->settings ?? []);
data_set($tenantSettings, 'ai_editing.budget.hard_cap_usd', 0.01);
$this->tenant->update(['settings' => $tenantSettings]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'tenant-budget-cap-style',
'name' => 'Tenant Budget Cap',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
AiUsageLedger::query()->create([
'tenant_id' => $this->tenant->id,
'event_id' => $event->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 0.02,
'amount_usd' => 0.02,
'currency' => 'USD',
'recorded_at' => now(),
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Apply AI style.',
'idempotency_key' => 'tenant-budget-cap-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'budget_hard_cap_reached')
->assertJsonPath('error.meta.budget.hard_reached', true);
}
public function test_tenant_prompt_block_records_abuse_metadata_when_escalation_threshold_is_reached(): void
{
config([
'ai-editing.abuse.escalation_threshold_per_hour' => 1,
'ai-editing.abuse.escalation_cooldown_minutes' => 1,
]);
AiEditingSetting::flushCache();
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
['blocked_terms' => ['weapon']]
));
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $this->tenant->id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'tenant-escalation-style',
'name' => 'Tenant Escalation',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
'photo_id' => $photo->id,
'style_id' => $style->id,
'prompt' => 'Add weapon effects.',
'idempotency_key' => 'tenant-escalation-1',
]);
$response->assertCreated()
->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED)
->assertJsonFragment(['abuse_escalation_threshold_reached']);
$requestId = (int) $response->json('data.id');
$editRequest = AiEditRequest::query()->find($requestId);
$this->assertNotNull($editRequest);
$this->assertIsArray($editRequest?->metadata);
$this->assertSame('prompt_block', $editRequest?->metadata['abuse']['type'] ?? null);
$this->assertTrue((bool) ($editRequest?->metadata['abuse']['escalated'] ?? false));
}
public function test_tenant_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void
{
$event = Event::factory()->create([
@@ -685,7 +829,10 @@ class TenantAiEditControllerTest extends TenantTestCase
->assertJsonPath('data.total', 2)
->assertJsonPath('data.status_counts.succeeded', 1)
->assertJsonPath('data.status_counts.failed', 1)
->assertJsonPath('data.failed_total', 1);
->assertJsonPath('data.failed_total', 1)
->assertJsonPath('data.usage.debit_count', 0)
->assertJsonPath('data.observability.provider_runs_total', 0)
->assertJsonPath('data.budget.hard_stop_enabled', true);
}
private function attachEntitledEventPackage(Event $event): EventPackage

View File

@@ -0,0 +1,172 @@
<?php
namespace Tests\Feature\Console;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Photo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AiEditsPruneCommandTest extends TestCase
{
use RefreshDatabase;
public function test_command_prunes_stale_requests_and_ledgers(): void
{
config([
'ai-editing.retention.request_days' => 90,
'ai-editing.retention.usage_ledger_days' => 365,
]);
[$oldRequest, $recentRequest] = $this->createRequestsForPruning();
[$oldLedger, $recentLedger] = $this->createLedgerEntriesForPruning($oldRequest, $recentRequest);
$this->artisan('ai-edits:prune')
->expectsOutputToContain('AI prune candidates')
->expectsOutputToContain('Pruned AI data')
->assertExitCode(0);
$this->assertDatabaseMissing('ai_edit_requests', ['id' => $oldRequest->id]);
$this->assertDatabaseMissing('ai_provider_runs', ['request_id' => $oldRequest->id]);
$this->assertDatabaseMissing('ai_edit_outputs', ['request_id' => $oldRequest->id]);
$this->assertDatabaseHas('ai_edit_requests', ['id' => $recentRequest->id]);
$this->assertDatabaseMissing('ai_usage_ledgers', ['id' => $oldLedger->id]);
$this->assertNotNull($recentLedger);
if ($recentLedger) {
$this->assertDatabaseHas('ai_usage_ledgers', ['id' => $recentLedger->id]);
}
}
public function test_command_pretend_mode_does_not_delete_records(): void
{
config([
'ai-editing.retention.request_days' => 90,
'ai-editing.retention.usage_ledger_days' => 365,
]);
[$oldRequest] = $this->createRequestsForPruning();
[$oldLedger] = $this->createLedgerEntriesForPruning($oldRequest, null);
$this->artisan('ai-edits:prune', ['--pretend' => true])
->expectsOutputToContain('AI prune candidates')
->expectsOutput('Pretend mode enabled. No records were deleted.')
->assertExitCode(0);
$this->assertDatabaseHas('ai_edit_requests', ['id' => $oldRequest->id]);
$this->assertDatabaseHas('ai_usage_ledgers', ['id' => $oldLedger->id]);
}
/**
* @return array{0: AiEditRequest, 1: AiEditRequest}
*/
private function createRequestsForPruning(): array
{
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'prune-style',
'name' => 'Prune Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$oldRequest = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'passed',
'prompt' => 'Old request',
'idempotency_key' => 'old-prune-request',
'queued_at' => now()->subDays(121),
'started_at' => now()->subDays(120),
'completed_at' => now()->subDays(120),
'expires_at' => now()->subDays(30),
]);
AiProviderRun::query()->create([
'request_id' => $oldRequest->id,
'provider' => 'runware',
'attempt' => 1,
'provider_task_id' => 'old-task-id',
'status' => AiProviderRun::STATUS_SUCCEEDED,
'started_at' => now()->subDays(120),
'finished_at' => now()->subDays(120),
]);
AiEditOutput::query()->create([
'request_id' => $oldRequest->id,
'provider_asset_id' => 'old-asset-id',
'provider_url' => 'https://cdn.example.invalid/old.jpg',
'safety_state' => 'passed',
'generated_at' => now()->subDays(120),
]);
$recentRequest = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'passed',
'prompt' => 'Recent request',
'idempotency_key' => 'recent-prune-request',
'queued_at' => now()->subDays(11),
'started_at' => now()->subDays(10),
'completed_at' => now()->subDays(10),
]);
return [$oldRequest, $recentRequest];
}
/**
* @return array{0: AiUsageLedger, 1: ?AiUsageLedger}
*/
private function createLedgerEntriesForPruning(AiEditRequest $oldRequest, ?AiEditRequest $recentRequest): array
{
$oldLedger = AiUsageLedger::query()->create([
'tenant_id' => $oldRequest->tenant_id,
'event_id' => $oldRequest->event_id,
'request_id' => $oldRequest->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 0.01,
'amount_usd' => 0.01,
'currency' => 'USD',
'recorded_at' => now()->subDays(400),
]);
if (! $recentRequest) {
return [$oldLedger, null];
}
$recentLedger = AiUsageLedger::query()->create([
'tenant_id' => $recentRequest->tenant_id,
'event_id' => $recentRequest->event_id,
'request_id' => $recentRequest->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 0.01,
'amount_usd' => 0.01,
'currency' => 'USD',
'recorded_at' => now()->subDays(30),
]);
return [$oldLedger, $recentLedger];
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace Tests\Feature\Console;
use App\Jobs\PollAiEditRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\Photo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class AiEditsRecoverStuckCommandTest extends TestCase
{
use RefreshDatabase;
public function test_command_requeues_stuck_requests_with_scope_aware_job_selection(): void
{
[$queuedRequest, $processingRequest] = $this->createStuckRequests();
Queue::fake();
$this->artisan('ai-edits:recover-stuck', [
'--minutes' => 10,
'--requeue' => true,
])->assertExitCode(0);
Queue::assertPushed(ProcessAiEditRequest::class, 1);
Queue::assertPushed(PollAiEditRequest::class, 1);
$this->assertDatabaseHas('ai_edit_requests', [
'id' => $queuedRequest->id,
'status' => AiEditRequest::STATUS_QUEUED,
]);
$this->assertDatabaseHas('ai_edit_requests', [
'id' => $processingRequest->id,
'status' => AiEditRequest::STATUS_PROCESSING,
]);
}
public function test_command_can_mark_stuck_requests_as_failed(): void
{
[$queuedRequest, $processingRequest] = $this->createStuckRequests();
$this->artisan('ai-edits:recover-stuck', [
'--minutes' => 10,
'--fail' => true,
])->assertExitCode(0);
$this->assertDatabaseHas('ai_edit_requests', [
'id' => $queuedRequest->id,
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'operator_recovery_marked_failed',
]);
$this->assertDatabaseHas('ai_edit_requests', [
'id' => $processingRequest->id,
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'operator_recovery_marked_failed',
]);
}
/**
* @return array{0: AiEditRequest, 1: AiEditRequest}
*/
private function createStuckRequests(): array
{
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'recovery-style',
'name' => 'Recovery Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$queued = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_QUEUED,
'safety_state' => 'pending',
'prompt' => 'Transform image style.',
'idempotency_key' => 'stuck-queued-1',
'queued_at' => now()->subMinutes(45),
'updated_at' => now()->subMinutes(45),
]);
$processing = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_PROCESSING,
'safety_state' => 'pending',
'prompt' => 'Transform image style.',
'idempotency_key' => 'stuck-processing-1',
'queued_at' => now()->subMinutes(50),
'started_at' => now()->subMinutes(40),
'updated_at' => now()->subMinutes(40),
]);
AiProviderRun::query()->create([
'request_id' => $processing->id,
'provider' => 'runware',
'attempt' => 1,
'provider_task_id' => 'runware-task-recovery-1',
'status' => AiProviderRun::STATUS_RUNNING,
'started_at' => now()->subMinutes(39),
]);
return [$queued, $processing];
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace Tests\Feature\Jobs;
use App\Jobs\PollAiEditRequest;
use App\Models\AiEditingSetting;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\Providers\RunwareAiImageProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use RuntimeException;
use Tests\TestCase;
class PollAiEditRequestTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
AiEditingSetting::flushCache();
}
public function test_it_marks_request_failed_when_poll_attempts_are_exhausted(): void
{
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
[
'runware_mode' => 'live',
'queue_auto_dispatch' => false,
'queue_max_polls' => 1,
]
));
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'poll-exhaust-style',
'name' => 'Poll Exhaust',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_PROCESSING,
'safety_state' => 'pending',
'prompt' => 'Transform image style.',
'idempotency_key' => 'poll-exhaust-1',
'queued_at' => now()->subMinutes(3),
'started_at' => now()->subMinutes(2),
]);
$providerRun = AiProviderRun::query()->create([
'request_id' => $request->id,
'provider' => 'runware',
'attempt' => 1,
'provider_task_id' => 'runware-task-1',
'status' => AiProviderRun::STATUS_RUNNING,
'started_at' => now()->subMinute(),
]);
$provider = \Mockery::mock(RunwareAiImageProvider::class);
$provider->shouldReceive('poll')
->once()
->withArgs(function (AiEditRequest $polledRequest, string $taskId): bool {
return $polledRequest->id > 0 && $taskId === 'runware-task-1';
})
->andReturn(AiProviderResult::processing('runware-task-1'));
$this->app->instance(RunwareAiImageProvider::class, $provider);
PollAiEditRequest::dispatchSync($request->id, 'runware-task-1', 1);
$request->refresh();
$providerRun->refresh();
$latestRun = $request->providerRuns()->latest('attempt')->first();
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
$this->assertSame('provider_poll_timeout', $request->failure_code);
$this->assertNotNull($request->completed_at);
$this->assertNotNull($latestRun);
$this->assertSame(AiProviderRun::STATUS_FAILED, $latestRun?->status);
$this->assertSame('Polling exhausted after 1 attempt(s).', $latestRun?->error_message);
}
public function test_poll_job_failed_hook_marks_request_as_failed(): void
{
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'poll-failed-hook-style',
'name' => 'Poll Failed Hook',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_PROCESSING,
'safety_state' => 'pending',
'prompt' => 'Transform image style.',
'idempotency_key' => 'poll-failed-hook-1',
'queued_at' => now()->subMinute(),
'started_at' => now()->subSeconds(30),
]);
$job = new PollAiEditRequest($request->id, 'runware-task-2', 2);
$job->failed(new RuntimeException('Polling crashed'));
$request->refresh();
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
$this->assertSame('queue_job_failed', $request->failure_code);
$this->assertSame('Polling crashed', $request->failure_message);
$this->assertNotNull($request->completed_at);
}
}

View File

@@ -9,7 +9,10 @@ use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\Providers\RunwareAiImageProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use RuntimeException;
use Tests\TestCase;
class ProcessAiEditRequestTest extends TestCase
@@ -187,5 +190,106 @@ class ProcessAiEditRequestTest extends TestCase
$this->assertNotNull($request->completed_at);
$this->assertSame(0, $request->outputs()->count());
$this->assertSame(1, $request->providerRuns()->count());
$this->assertIsArray($request->metadata);
$this->assertSame('output_block', $request->metadata['abuse']['type'] ?? null);
}
public function test_it_marks_request_failed_when_provider_returns_processing_without_task_id(): void
{
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
[
'runware_mode' => 'live',
'queue_auto_dispatch' => false,
]
));
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'processing-no-task',
'name' => 'Processing Without Task ID',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_QUEUED,
'safety_state' => 'pending',
'prompt' => 'Transform image style.',
'idempotency_key' => 'job-processing-no-task',
'queued_at' => now(),
]);
$provider = \Mockery::mock(RunwareAiImageProvider::class);
$provider->shouldReceive('submit')
->once()
->andReturn(new AiProviderResult(status: 'processing', providerTaskId: ''));
$this->app->instance(RunwareAiImageProvider::class, $provider);
ProcessAiEditRequest::dispatchSync($request->id);
$request->refresh();
$run = $request->providerRuns()->first();
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
$this->assertSame('provider_task_id_missing', $request->failure_code);
$this->assertNotNull($request->completed_at);
$this->assertNotNull($run);
$this->assertSame('failed', $run?->status);
$this->assertSame('Provider returned processing state without task identifier.', $run?->error_message);
}
public function test_process_job_failed_hook_marks_request_as_failed(): void
{
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'failed-hook-style',
'name' => 'Failed Hook Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => AiEditRequest::STATUS_PROCESSING,
'safety_state' => 'pending',
'prompt' => 'Transform image style.',
'idempotency_key' => 'job-failed-hook-1',
'queued_at' => now()->subMinute(),
'started_at' => now()->subSeconds(30),
]);
$job = new ProcessAiEditRequest($request->id);
$job->failed(new RuntimeException('Queue worker timeout'));
$request->refresh();
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
$this->assertSame('queue_job_failed', $request->failure_code);
$this->assertSame('Queue worker timeout', $request->failure_message);
$this->assertNotNull($request->completed_at);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Unit\Models;
use App\Models\AiStyle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AiStyleVersioningTest extends TestCase
{
use RefreshDatabase;
public function test_style_defaults_to_version_one_on_create(): void
{
$style = AiStyle::query()->create([
'key' => 'style-version-default',
'name' => 'Version Default',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
]);
$this->assertSame(1, $style->version);
}
public function test_style_version_increments_when_core_style_fields_change(): void
{
$style = AiStyle::query()->create([
'key' => 'style-version-increment',
'name' => 'Version Increment',
'provider' => 'runware',
'provider_model' => 'runware-default',
'prompt_template' => 'Initial prompt',
'is_active' => true,
]);
$this->assertSame(1, $style->version);
$style->update([
'prompt_template' => 'Updated prompt',
]);
$style->refresh();
$this->assertSame(2, $style->version);
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace Tests\Unit\Services;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\User;
use App\Services\AiEditing\AiBudgetGuardService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class AiBudgetGuardServiceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_it_allows_requests_when_spend_is_below_caps(): void
{
$event = Event::factory()->create(['status' => 'published']);
$settings = (array) ($event->tenant->settings ?? []);
data_set($settings, 'ai_editing.budget.soft_cap_usd', 10.0);
data_set($settings, 'ai_editing.budget.hard_cap_usd', 20.0);
$event->tenant->update(['settings' => $settings]);
AiUsageLedger::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 3.0,
'amount_usd' => 3.0,
'currency' => 'USD',
'recorded_at' => now(),
]);
$decision = app(AiBudgetGuardService::class)->evaluateForEvent($event->fresh('tenant'));
$this->assertTrue($decision['allowed']);
$this->assertFalse($decision['budget']['soft_reached']);
$this->assertFalse($decision['budget']['hard_reached']);
$this->assertSame(3.0, $decision['budget']['current_spend_usd']);
}
public function test_it_blocks_requests_when_hard_cap_is_reached_without_override(): void
{
$event = Event::factory()->create(['status' => 'published']);
$owner = User::factory()->create();
$event->tenant->update(['user_id' => $owner->id]);
$settings = (array) ($event->tenant->settings ?? []);
data_set($settings, 'ai_editing.budget.hard_cap_usd', 5.0);
$event->tenant->update(['settings' => $settings]);
AiUsageLedger::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 5.0,
'amount_usd' => 5.0,
'currency' => 'USD',
'recorded_at' => now(),
]);
$decision = app(AiBudgetGuardService::class)->evaluateForEvent($event->fresh('tenant'));
$this->assertFalse($decision['allowed']);
$this->assertSame('budget_hard_cap_reached', $decision['reason_code']);
$this->assertTrue($decision['budget']['hard_reached']);
$this->assertFalse($decision['budget']['override_active']);
$this->assertDatabaseHas('tenant_notification_logs', [
'tenant_id' => $event->tenant_id,
'type' => 'ai_budget_hard_cap',
'channel' => 'system',
'status' => 'sent',
]);
$this->assertDatabaseHas('tenant_notification_receipts', [
'tenant_id' => $event->tenant_id,
'user_id' => $owner->id,
'status' => 'delivered',
]);
}
public function test_it_throttles_soft_cap_notifications_with_cooldown(): void
{
$event = Event::factory()->create(['status' => 'published']);
$settings = (array) ($event->tenant->settings ?? []);
data_set($settings, 'ai_editing.budget.soft_cap_usd', 2.0);
data_set($settings, 'ai_editing.budget.hard_cap_usd', 100.0);
$event->tenant->update(['settings' => $settings]);
AiUsageLedger::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 3.0,
'amount_usd' => 3.0,
'currency' => 'USD',
'recorded_at' => now(),
]);
$service = app(AiBudgetGuardService::class);
$service->evaluateForEvent($event->fresh('tenant'));
$service->evaluateForEvent($event->fresh('tenant'));
$this->assertSame(
1,
\App\Models\TenantNotificationLog::query()
->where('tenant_id', $event->tenant_id)
->where('type', 'ai_budget_soft_cap')
->count()
);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Tests\Unit\Services;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiObservabilityService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
class AiObservabilityServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_records_terminal_outcomes_in_cache(): void
{
$request = $this->makeRequest(AiEditRequest::STATUS_PROCESSING);
app(AiObservabilityService::class)->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_SUCCEEDED,
1200,
false,
'process'
);
$bucket = now()->format('YmdH');
$prefix = sprintf('ai-editing:obs:tenant:%d:event:%d:hour:%s', $request->tenant_id, $request->event_id, $bucket);
$this->assertSame(1, (int) Cache::get($prefix.':total'));
$this->assertSame(1, (int) Cache::get($prefix.':succeeded'));
$this->assertSame(1200, (int) Cache::get($prefix.':duration_total_ms'));
}
public function test_it_logs_failure_rate_alert_when_threshold_is_reached(): void
{
config([
'ai-editing.observability.failure_rate_alert_threshold' => 0.5,
'ai-editing.observability.failure_rate_min_samples' => 1,
]);
$request = $this->makeRequest(AiEditRequest::STATUS_PROCESSING);
Log::spy();
app(AiObservabilityService::class)->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
500,
false,
'poll'
);
Log::shouldHaveReceived('warning')
->withArgs(function (string $message, array $context): bool {
return $message === 'AI failure-rate alert threshold reached'
&& isset($context['failure_rate'])
&& $context['failure_rate'] >= 0.5;
})
->once();
}
private function makeRequest(string $status): AiEditRequest
{
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'obs-style',
'name' => 'Observability Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'is_active' => true,
]);
return AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'provider' => 'runware',
'provider_model' => 'runware-default',
'status' => $status,
'safety_state' => 'pending',
'prompt' => 'Observability',
'idempotency_key' => 'obs-'.uniqid('', true),
'queued_at' => now()->subMinute(),
'started_at' => now()->subSeconds(30),
]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace Tests\Unit\Services;
use App\Models\AiEditRequest;
use App\Models\Event;
use App\Models\Photo;
use App\Models\User;
use App\Services\AiEditing\AiStatusNotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class AiStatusNotificationServiceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_it_creates_guest_and_tenant_notifications_for_guest_requests(): void
{
$event = Event::factory()->create(['status' => 'published']);
$owner = User::factory()->create();
$event->tenant->update(['user_id' => $owner->id]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'status' => AiEditRequest::STATUS_SUCCEEDED,
'safety_state' => 'passed',
'requested_by_device_id' => 'device-ai-1',
'idempotency_key' => 'notify-guest-success-1',
'queued_at' => now()->subMinute(),
'completed_at' => now(),
]);
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request);
$this->assertDatabaseHas('guest_notifications', [
'event_id' => $event->id,
'type' => 'upload_alert',
'audience_scope' => 'guest',
'target_identifier' => 'device-ai-1',
]);
$this->assertDatabaseHas('tenant_notification_logs', [
'tenant_id' => $event->tenant_id,
'type' => 'ai_edit_succeeded',
'channel' => 'system',
'status' => 'sent',
]);
$this->assertDatabaseHas('tenant_notification_receipts', [
'tenant_id' => $event->tenant_id,
'user_id' => $owner->id,
'status' => 'delivered',
]);
}
public function test_it_creates_only_tenant_notification_for_tenant_admin_requests(): void
{
$event = Event::factory()->create(['status' => 'published']);
$owner = User::factory()->create();
$event->tenant->update(['user_id' => $owner->id]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'requested_by_user_id' => $owner->id,
'status' => AiEditRequest::STATUS_FAILED,
'safety_state' => 'pending',
'failure_code' => 'provider_timeout',
'idempotency_key' => 'notify-tenant-failed-1',
'queued_at' => now()->subMinute(),
'completed_at' => now(),
]);
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request);
$this->assertDatabaseCount('guest_notifications', 0);
$this->assertDatabaseHas('tenant_notification_logs', [
'tenant_id' => $event->tenant_id,
'type' => 'ai_edit_failed',
'channel' => 'system',
'status' => 'sent',
]);
}
public function test_it_deduplicates_terminal_notifications_per_request_and_status(): void
{
$event = Event::factory()->create(['status' => 'published']);
$owner = User::factory()->create();
$event->tenant->update(['user_id' => $owner->id]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$request = AiEditRequest::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'photo_id' => $photo->id,
'status' => AiEditRequest::STATUS_BLOCKED,
'safety_state' => 'blocked',
'requested_by_device_id' => 'device-ai-dup',
'idempotency_key' => 'notify-dedupe-1',
'queued_at' => now()->subMinute(),
'completed_at' => now(),
]);
$service = app(AiStatusNotificationService::class);
$service->notifyTerminalOutcome($request);
$service->notifyTerminalOutcome($request->fresh());
$this->assertDatabaseCount('guest_notifications', 1);
$this->assertDatabaseCount('tenant_notification_logs', 1);
}
}