feat: implement AI styling foundation and billing scope rework
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 20:01:58 +01:00
parent df00deb0df
commit 36bed12ff9
80 changed files with 8944 additions and 49 deletions

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiEditOutput extends Model
{
use HasFactory;
protected $fillable = [
'request_id',
'photo_id',
'storage_disk',
'storage_path',
'mime_type',
'width',
'height',
'bytes',
'checksum',
'provider_asset_id',
'provider_url',
'is_primary',
'safety_state',
'safety_reasons',
'generated_at',
'metadata',
];
protected function casts(): array
{
return [
'is_primary' => 'boolean',
'safety_reasons' => 'array',
'metadata' => 'array',
'generated_at' => 'datetime',
];
}
public function request(): BelongsTo
{
return $this->belongsTo(AiEditRequest::class, 'request_id');
}
public function photo(): BelongsTo
{
return $this->belongsTo(Photo::class);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AiEditRequest extends Model
{
use HasFactory;
public const STATUS_QUEUED = 'queued';
public const STATUS_PROCESSING = 'processing';
public const STATUS_SUCCEEDED = 'succeeded';
public const STATUS_FAILED = 'failed';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_CANCELED = 'canceled';
protected $fillable = [
'tenant_id',
'event_id',
'photo_id',
'style_id',
'requested_by_user_id',
'provider',
'provider_model',
'status',
'safety_state',
'prompt',
'negative_prompt',
'input_image_path',
'requested_by_device_id',
'requested_by_session_id',
'idempotency_key',
'safety_reasons',
'failure_code',
'failure_message',
'queued_at',
'started_at',
'completed_at',
'expires_at',
'metadata',
];
protected function casts(): array
{
return [
'safety_reasons' => 'array',
'metadata' => 'array',
'queued_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function photo(): BelongsTo
{
return $this->belongsTo(Photo::class);
}
public function style(): BelongsTo
{
return $this->belongsTo(AiStyle::class, 'style_id');
}
public function requestedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by_user_id');
}
public function outputs(): HasMany
{
return $this->hasMany(AiEditOutput::class, 'request_id');
}
public function providerRuns(): HasMany
{
return $this->hasMany(AiProviderRun::class, 'request_id');
}
public function usageLedgers(): HasMany
{
return $this->hasMany(AiUsageLedger::class, 'request_id');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Throwable;
class AiEditingSetting extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'is_enabled' => 'boolean',
'queue_auto_dispatch' => 'boolean',
'queue_max_polls' => 'integer',
'blocked_terms' => 'array',
];
}
protected static function booted(): void
{
static::saved(fn () => static::flushCache());
static::deleted(fn () => static::flushCache());
}
public static function current(): self
{
/** @var self */
return Cache::remember('ai_editing.settings', now()->addMinutes(10), static function (): self {
try {
return static::query()->firstOrCreate(['id' => 1], static::defaults());
} catch (Throwable) {
return new static(static::defaults());
}
});
}
/**
* @return array<string, mixed>
*/
public static function defaults(): array
{
return [
'is_enabled' => true,
'default_provider' => (string) config('ai-editing.default_provider', 'runware'),
'fallback_provider' => null,
'runware_mode' => (string) config('ai-editing.providers.runware.mode', 'live'),
'queue_auto_dispatch' => (bool) config('ai-editing.queue.auto_dispatch', false),
'queue_name' => (string) config('ai-editing.queue.name', 'default'),
'queue_max_polls' => max(1, (int) config('ai-editing.queue.max_polls', 6)),
'blocked_terms' => array_values(array_filter((array) config('ai-editing.safety.prompt.blocked_terms', []))),
'status_message' => null,
];
}
public static function flushCache(): void
{
Cache::forget('ai_editing.settings');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiProviderRun extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCEEDED = 'succeeded';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'request_id',
'provider',
'attempt',
'provider_task_id',
'status',
'http_status',
'started_at',
'finished_at',
'duration_ms',
'cost_usd',
'tokens_input',
'tokens_output',
'request_payload',
'response_payload',
'error_message',
'metadata',
];
protected function casts(): array
{
return [
'request_payload' => 'array',
'response_payload' => 'array',
'metadata' => 'array',
'cost_usd' => 'decimal:5',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
}
public function request(): BelongsTo
{
return $this->belongsTo(AiEditRequest::class, 'request_id');
}
}

44
app/Models/AiStyle.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AiStyle extends Model
{
use HasFactory;
protected $fillable = [
'key',
'name',
'category',
'description',
'prompt_template',
'negative_prompt_template',
'provider',
'provider_model',
'requires_source_image',
'is_premium',
'is_active',
'sort',
'metadata',
];
protected function casts(): array
{
return [
'requires_source_image' => 'boolean',
'is_premium' => 'boolean',
'is_active' => 'boolean',
'sort' => 'integer',
'metadata' => 'array',
];
}
public function editRequests(): HasMany
{
return $this->hasMany(AiEditRequest::class, 'style_id');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AiUsageLedger extends Model
{
use HasFactory;
public const TYPE_DEBIT = 'debit';
public const TYPE_CREDIT = 'credit';
public const TYPE_REFUND = 'refund';
public const TYPE_ADJUSTMENT = 'adjustment';
protected $fillable = [
'tenant_id',
'event_id',
'request_id',
'entry_type',
'quantity',
'unit_cost_usd',
'amount_usd',
'currency',
'package_context',
'notes',
'recorded_at',
'metadata',
];
protected function casts(): array
{
return [
'unit_cost_usd' => 'decimal:5',
'amount_usd' => 'decimal:5',
'recorded_at' => 'datetime',
'metadata' => 'array',
];
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function request(): BelongsTo
{
return $this->belongsTo(AiEditRequest::class, 'request_id');
}
}

View File

@@ -88,6 +88,11 @@ class Event extends Model
return $this->hasMany(Photo::class);
}
public function aiEditRequests(): HasMany
{
return $this->hasMany(AiEditRequest::class);
}
public function taskCollections(): BelongsToMany
{
return $this->belongsToMany(

View File

@@ -149,6 +149,11 @@ class Photo extends Model
return $this->hasMany(PhotoShareLink::class);
}
public function aiEditRequests(): HasMany
{
return $this->hasMany(AiEditRequest::class);
}
public static function supportsFilenameColumn(): bool
{
return static::hasColumn('filename');