optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use App\Services\ThumbnailService;
use Illuminate\Support\Facades\DB;
use Laravel\Scout\Searchable;
@@ -27,6 +26,10 @@ class Artwork extends Model
{
use HasFactory, SoftDeletes, Searchable;
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
protected $table = 'artworks';
protected $fillable = [
@@ -44,11 +47,23 @@ class Artwork extends Model
'width',
'height',
'is_public',
'visibility',
'is_approved',
'is_mature',
'published_at',
'hash',
'thumb_ext',
'file_ext',
'clip_tags_json',
'blip_caption',
'yolo_objects_json',
'vision_metadata_updated_at',
'last_vector_indexed_at',
'ai_status',
'title_source',
'description_source',
'tags_source',
'category_source',
// Versioning
'current_version_id',
'version_count',
@@ -62,9 +77,20 @@ class Artwork extends Model
protected $casts = [
'is_public' => 'boolean',
'visibility' => 'string',
'is_approved' => 'boolean',
'is_mature' => 'boolean',
'published_at' => 'datetime',
'publish_at' => 'datetime',
'clip_tags_json' => 'array',
'yolo_objects_json' => 'array',
'vision_metadata_updated_at' => 'datetime',
'last_vector_indexed_at' => 'datetime',
'ai_status' => 'string',
'title_source' => 'string',
'description_source' => 'string',
'tags_source' => 'string',
'category_source' => 'string',
'version_updated_at' => 'datetime',
'requires_reapproval' => 'boolean',
];
@@ -156,6 +182,14 @@ class Artwork extends Model
return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id');
}
public function collections(): BelongsToMany
{
return $this->belongsToMany(Collection::class, 'collection_artwork', 'artwork_id', 'collection_id')
->withPivot(['order_num'])
->withTimestamps()
->orderByPivot('order_num');
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'artwork_tag', 'artwork_id', 'tag_id')
@@ -218,6 +252,11 @@ class Artwork extends Model
return $this->belongsTo(ArtworkVersion::class, 'current_version_id');
}
public function artworkAiAssist(): HasOne
{
return $this->hasOne(ArtworkAiAssist::class, 'artwork_id');
}
public function awardStat(): HasOne
{
return $this->hasOne(ArtworkAwardStat::class);
@@ -267,6 +306,17 @@ class Artwork extends Model
'category' => $category,
'content_type' => $content_type,
'tags' => $tags,
'ai_clip_tags' => collect((array) ($this->clip_tags_json ?? []))
->map(static fn ($row) => is_array($row) ? (string) ($row['tag'] ?? '') : '')
->filter()
->values()
->all(),
'ai_blip_caption' => (string) ($this->blip_caption ?? ''),
'ai_yolo_objects' => collect((array) ($this->yolo_objects_json ?? []))
->map(static fn ($row) => is_array($row) ? (string) ($row['tag'] ?? '') : '')
->filter()
->values()
->all(),
'resolution' => $resolution,
'orientation' => $orientation,
'downloads' => (int) ($stat?->downloads ?? 0),
@@ -276,6 +326,7 @@ class Artwork extends Model
'is_public' => (bool) $this->is_public,
'is_approved' => (bool) $this->is_approved,
// ── Trending / discovery fields ────────────────────────────────────
'trending_score_1h' => (float) ($this->trending_score_1h ?? 0),
'trending_score_24h' => (float) ($this->trending_score_24h ?? 0),
'trending_score_7d' => (float) ($this->trending_score_7d ?? 0),
'favorites_count' => (int) ($stat?->favorites ?? 0),

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkAiAssist extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_QUEUED = 'queued';
public const STATUS_PROCESSING = 'processing';
public const STATUS_READY = 'ready';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'artwork_id',
'status',
'mode',
'title_suggestions_json',
'description_suggestions_json',
'tag_suggestions_json',
'category_suggestions_json',
'similar_candidates_json',
'raw_response_json',
'action_log_json',
'error_message',
'processed_at',
];
protected $casts = [
'title_suggestions_json' => 'array',
'description_suggestions_json' => 'array',
'tag_suggestions_json' => 'array',
'category_suggestions_json' => 'array',
'similar_candidates_json' => 'array',
'raw_response_json' => 'array',
'action_log_json' => 'array',
'processed_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkAiAssistEvent extends Model
{
protected $fillable = [
'artwork_ai_assist_id',
'artwork_id',
'user_id',
'event_type',
'meta',
];
protected $casts = [
'meta' => 'array',
];
public function assist(): BelongsTo
{
return $this->belongsTo(ArtworkAiAssist::class, 'artwork_ai_assist_id');
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

673
app/Models/Collection.php Normal file
View File

@@ -0,0 +1,673 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\CollectionComment;
use App\Models\CollectionDailyStat;
use App\Models\CollectionHistory;
use App\Models\CollectionMember;
use App\Models\CollectionSave;
use App\Models\CollectionSurfacePlacement;
use App\Models\CollectionSubmission;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Collection extends Model
{
use HasFactory, SoftDeletes;
public const LIFECYCLE_DRAFT = 'draft';
public const LIFECYCLE_SCHEDULED = 'scheduled';
public const LIFECYCLE_PUBLISHED = 'published';
public const LIFECYCLE_FEATURED = 'featured';
public const LIFECYCLE_ARCHIVED = 'archived';
public const LIFECYCLE_HIDDEN = 'hidden';
public const LIFECYCLE_RESTRICTED = 'restricted';
public const LIFECYCLE_UNDER_REVIEW = 'under_review';
public const LIFECYCLE_EXPIRED = 'expired';
public const WORKFLOW_DRAFT = 'draft';
public const WORKFLOW_IN_REVIEW = 'in_review';
public const WORKFLOW_APPROVED = 'approved';
public const WORKFLOW_PROGRAMMED = 'programmed';
public const WORKFLOW_ARCHIVED = 'archived';
public const READINESS_READY = 'ready';
public const READINESS_NEEDS_WORK = 'needs_work';
public const READINESS_BLOCKED = 'blocked';
public const HEALTH_HEALTHY = 'healthy';
public const HEALTH_NEEDS_METADATA = 'needs_metadata';
public const HEALTH_STALE = 'stale';
public const HEALTH_LOW_CONTENT = 'low_content';
public const HEALTH_BROKEN_ITEMS = 'broken_items';
public const HEALTH_WEAK_COVER = 'weak_cover';
public const HEALTH_LOW_ENGAGEMENT = 'low_engagement';
public const HEALTH_ATTRIBUTION_INCOMPLETE = 'attribution_incomplete';
public const HEALTH_NEEDS_REVIEW = 'needs_review';
public const HEALTH_DUPLICATE_RISK = 'duplicate_risk';
public const HEALTH_MERGE_CANDIDATE = 'merge_candidate';
public const TYPE_PERSONAL = 'personal';
public const TYPE_COMMUNITY = 'community';
public const TYPE_EDITORIAL = 'editorial';
public const EDITORIAL_OWNER_CREATOR = 'creator';
public const EDITORIAL_OWNER_STAFF_ACCOUNT = 'staff_account';
public const EDITORIAL_OWNER_SYSTEM = 'system';
public const COLLABORATION_CLOSED = 'closed';
public const COLLABORATION_INVITE_ONLY = 'invite_only';
public const COLLABORATION_OPEN = 'open';
public const MODERATION_ACTIVE = 'active';
public const MODERATION_UNDER_REVIEW = 'under_review';
public const MODERATION_REVIEW = self::MODERATION_UNDER_REVIEW;
public const MODERATION_RESTRICTED = 'restricted';
public const MODERATION_HIDDEN = 'hidden';
public const MEMBER_ROLE_OWNER = 'owner';
public const MEMBER_ROLE_EDITOR = 'editor';
public const MEMBER_ROLE_CONTRIBUTOR = 'contributor';
public const MEMBER_ROLE_VIEWER = 'viewer';
public const MEMBER_STATUS_PENDING = 'pending';
public const MEMBER_STATUS_ACTIVE = 'active';
public const MEMBER_STATUS_REVOKED = 'revoked';
public const SUBMISSION_PENDING = 'pending';
public const SUBMISSION_APPROVED = 'approved';
public const SUBMISSION_REJECTED = 'rejected';
public const SUBMISSION_WITHDRAWN = 'withdrawn';
public const COMMENT_VISIBLE = 'visible';
public const COMMENT_HIDDEN = 'hidden';
public const COMMENT_FLAGGED = 'flagged';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
public const MODE_MANUAL = 'manual';
public const MODE_SMART = 'smart';
public const SORT_MANUAL = 'manual';
public const SORT_NEWEST = 'newest';
public const SORT_OLDEST = 'oldest';
public const SORT_POPULAR = 'popular';
public const SPOTLIGHT_STYLE_DEFAULT = 'default';
public const SPOTLIGHT_STYLE_EDITORIAL = 'editorial';
public const SPOTLIGHT_STYLE_SEASONAL = 'seasonal';
public const SPOTLIGHT_STYLE_CHALLENGE = 'challenge';
public const SPOTLIGHT_STYLE_COMMUNITY = 'community';
public const PRESENTATION_STANDARD = 'standard';
public const PRESENTATION_EDITORIAL_GRID = 'editorial_grid';
public const PRESENTATION_HERO_GRID = 'hero_grid';
public const PRESENTATION_MASONRY = 'masonry';
public const EMPHASIS_COVER_HEAVY = 'cover_heavy';
public const EMPHASIS_BALANCED = 'balanced';
public const EMPHASIS_ARTWORK_FIRST = 'artwork_first';
protected $fillable = [
'user_id',
'managed_by_user_id',
'title',
'slug',
'lifecycle_state',
'workflow_state',
'readiness_state',
'health_state',
'health_flags_json',
'canonical_collection_id',
'duplicate_cluster_key',
'program_key',
'partner_key',
'trust_tier',
'experiment_key',
'experiment_treatment',
'placement_variant',
'ranking_mode_variant',
'collection_pool_version',
'test_label',
'recommendation_tier',
'ranking_bucket',
'search_boost_tier',
'type',
'editorial_owner_mode',
'editorial_owner_user_id',
'editorial_owner_label',
'description',
'subtitle',
'summary',
'collaboration_mode',
'allow_submissions',
'allow_comments',
'allow_saves',
'moderation_status',
'published_at',
'unpublished_at',
'event_key',
'event_label',
'season_key',
'banner_text',
'badge_label',
'spotlight_style',
'quality_score',
'ranking_score',
'metadata_completeness_score',
'editorial_readiness_score',
'freshness_score',
'engagement_score',
'health_score',
'last_health_check_at',
'last_recommendation_refresh_at',
'placement_eligibility',
'analytics_enabled',
'presentation_style',
'emphasis_mode',
'theme_token',
'series_key',
'series_title',
'series_description',
'series_order',
'campaign_key',
'campaign_label',
'commercial_eligibility',
'promotion_tier',
'sponsorship_label',
'sponsorship_state',
'partner_label',
'ownership_domain',
'commercial_review_state',
'legal_review_state',
'monetization_ready_status',
'brand_safe_status',
'editorial_notes',
'staff_commercial_notes',
'archived_at',
'expired_at',
'history_count',
'cover_artwork_id',
'visibility',
'mode',
'sort_mode',
'artworks_count',
'comments_count',
'saves_count',
'collaborators_count',
'is_featured',
'profile_order',
'views_count',
'likes_count',
'followers_count',
'shares_count',
'smart_rules_json',
'layout_modules_json',
'last_activity_at',
'featured_at',
];
protected $casts = [
'artworks_count' => 'integer',
'comments_count' => 'integer',
'saves_count' => 'integer',
'collaborators_count' => 'integer',
'is_featured' => 'boolean',
'profile_order' => 'integer',
'views_count' => 'integer',
'likes_count' => 'integer',
'followers_count' => 'integer',
'shares_count' => 'integer',
'allow_submissions' => 'boolean',
'allow_comments' => 'boolean',
'allow_saves' => 'boolean',
'analytics_enabled' => 'boolean',
'commercial_eligibility' => 'boolean',
'smart_rules_json' => 'array',
'layout_modules_json' => 'array',
'health_flags_json' => 'array',
'quality_score' => 'decimal:2',
'ranking_score' => 'decimal:2',
'metadata_completeness_score' => 'decimal:2',
'editorial_readiness_score' => 'decimal:2',
'freshness_score' => 'decimal:2',
'engagement_score' => 'decimal:2',
'health_score' => 'decimal:2',
'placement_eligibility' => 'boolean',
'series_order' => 'integer',
'history_count' => 'integer',
'last_activity_at' => 'datetime',
'featured_at' => 'datetime',
'last_health_check_at' => 'datetime',
'last_recommendation_refresh_at' => 'datetime',
'published_at' => 'datetime',
'unpublished_at' => 'datetime',
'archived_at' => 'datetime',
'expired_at' => 'datetime',
];
protected ?bool $cachedVisibilityWindow = null;
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function managedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'managed_by_user_id');
}
public function editorialOwnerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'editorial_owner_user_id');
}
public function coverArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'cover_artwork_id');
}
public function likes(): HasMany
{
return $this->hasMany(CollectionLike::class);
}
public function saves(): HasMany
{
return $this->hasMany(CollectionSave::class);
}
public function follows(): HasMany
{
return $this->hasMany(CollectionFollow::class);
}
public function members(): HasMany
{
return $this->hasMany(CollectionMember::class);
}
public function submissions(): HasMany
{
return $this->hasMany(CollectionSubmission::class);
}
public function comments(): HasMany
{
return $this->hasMany(CollectionComment::class);
}
public function historyEntries(): HasMany
{
return $this->hasMany(CollectionHistory::class);
}
public function canonicalCollection(): BelongsTo
{
return $this->belongsTo(self::class, 'canonical_collection_id');
}
public function dailyStats(): HasMany
{
return $this->hasMany(CollectionDailyStat::class);
}
public function programAssignments(): HasMany
{
return $this->hasMany(CollectionProgramAssignment::class);
}
public function qualitySnapshots(): HasMany
{
return $this->hasMany(CollectionQualitySnapshot::class);
}
public function recommendationSnapshots(): HasMany
{
return $this->hasMany(CollectionRecommendationSnapshot::class);
}
public function mergeActionsAsSource(): HasMany
{
return $this->hasMany(CollectionMergeAction::class, 'source_collection_id');
}
public function mergeActionsAsTarget(): HasMany
{
return $this->hasMany(CollectionMergeAction::class, 'target_collection_id');
}
public function entityLinks(): HasMany
{
return $this->hasMany(CollectionEntityLink::class);
}
public function savedNotes(): HasMany
{
return $this->hasMany(CollectionSavedNote::class);
}
public function placements(): HasMany
{
return $this->hasMany(CollectionSurfacePlacement::class);
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'collection_artwork', 'collection_id', 'artwork_id')
->withPivot(['order_num'])
->withTimestamps()
->orderByPivot('order_num');
}
public function publicArtworks(): BelongsToMany
{
return $this->artworks()
->whereNull('artworks.deleted_at')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now());
}
public function manualRelatedCollections(): BelongsToMany
{
return $this->belongsToMany(self::class, 'collection_related_links', 'collection_id', 'related_collection_id')
->withPivot(['sort_order', 'created_by_user_id'])
->withTimestamps()
->orderBy('collection_related_links.sort_order')
->orderBy('collection_related_links.id');
}
public function scopePublic(Builder $query): Builder
{
return $query
->where('visibility', self::VISIBILITY_PUBLIC)
->where('moderation_status', self::MODERATION_ACTIVE)
->whereNotIn('lifecycle_state', [
self::LIFECYCLE_DRAFT,
self::LIFECYCLE_EXPIRED,
self::LIFECYCLE_HIDDEN,
self::LIFECYCLE_RESTRICTED,
self::LIFECYCLE_UNDER_REVIEW,
])
->where(function (Builder $builder): void {
$builder->whereNull('published_at')
->orWhere('published_at', '<=', now());
})
->where(function (Builder $builder): void {
$builder->whereNull('unpublished_at')
->orWhere('unpublished_at', '>', now());
})
->where(function (Builder $builder): void {
$builder->whereNull('expired_at')
->orWhere('expired_at', '>', now());
});
}
public function scopePublicEligible(Builder $query): Builder
{
return $query
->public()
->where('placement_eligibility', true)
->whereIn('lifecycle_state', [
self::LIFECYCLE_PUBLISHED,
self::LIFECYCLE_FEATURED,
]);
}
public function scopeFeaturedPublic(Builder $query): Builder
{
return $query
->publicEligible()
->where('is_featured', true);
}
public function scopeVisibleOnProfile(Builder $query): Builder
{
return $query->public();
}
public function scopeOwnedBy(Builder $query, int $userId): Builder
{
return $query->where('user_id', $userId);
}
public function isOwnedBy(User|int|null $user): bool
{
$userId = $user instanceof User ? $user->id : $user;
return $userId !== null && (int) $userId === (int) $this->user_id;
}
public function isPubliclyAccessible(): bool
{
if ($this->moderation_status !== self::MODERATION_ACTIVE) {
return false;
}
if ($this->published_at && $this->published_at->isFuture()) {
return false;
}
if ($this->unpublished_at && $this->unpublished_at->lte(now())) {
return false;
}
if ($this->expired_at && $this->expired_at->lte(now()) && $this->lifecycle_state === self::LIFECYCLE_EXPIRED) {
return false;
}
if (! in_array($this->lifecycle_state, [
self::LIFECYCLE_SCHEDULED,
self::LIFECYCLE_PUBLISHED,
self::LIFECYCLE_FEATURED,
self::LIFECYCLE_ARCHIVED,
], true)) {
return false;
}
return in_array($this->visibility, [self::VISIBILITY_PUBLIC, self::VISIBILITY_UNLISTED], true);
}
public function isPubliclyEngageable(): bool
{
return $this->visibility === self::VISIBILITY_PUBLIC;
}
public function displayOwnerName(): string
{
if ($this->type === self::TYPE_EDITORIAL && $this->editorial_owner_mode === self::EDITORIAL_OWNER_SYSTEM) {
return (string) ($this->editorial_owner_label ?: config('collections.editorial.system_owner_label', 'Skinbase Editorial'));
}
$owner = $this->relationLoaded('user') ? $this->user : $this->user()->first();
return (string) ($owner?->name ?: $owner?->username ?: 'Skinbase Curator');
}
public function displayOwnerUsername(): ?string
{
if ($this->type === self::TYPE_EDITORIAL && $this->editorial_owner_mode === self::EDITORIAL_OWNER_SYSTEM) {
return null;
}
$owner = $this->relationLoaded('user') ? $this->user : $this->user()->first();
return $owner?->username;
}
public function hasSystemEditorialOwner(): bool
{
return $this->type === self::TYPE_EDITORIAL && $this->editorial_owner_mode === self::EDITORIAL_OWNER_SYSTEM;
}
public function isCollaborative(): bool
{
return $this->type !== self::TYPE_PERSONAL || $this->collaboration_mode !== self::COLLABORATION_CLOSED;
}
public function activeMemberRoleFor(User|int|null $user): ?string
{
$userId = $user instanceof User ? $user->id : $user;
if ($userId === null) {
return null;
}
if ($this->isOwnedBy($userId)) {
return self::MEMBER_ROLE_OWNER;
}
$members = $this->relationLoaded('members')
? $this->members
: $this->members()->where('status', self::MEMBER_STATUS_ACTIVE)->get();
return $members
->first(fn (CollectionMember $member) => (int) $member->user_id === (int) $userId && $member->status === self::MEMBER_STATUS_ACTIVE)
?->role;
}
public function hasActiveMember(User|int|null $user): bool
{
return $this->activeMemberRoleFor($user) !== null;
}
public function canBeManagedBy(User $user): bool
{
return in_array($this->activeMemberRoleFor($user), [
self::MEMBER_ROLE_OWNER,
self::MEMBER_ROLE_EDITOR,
], true);
}
public function canManageArtworks(User $user): bool
{
return $this->canBeManagedBy($user);
}
public function canManageMembers(User $user): bool
{
return $this->isCollaborative() && $this->canBeManagedBy($user);
}
public function canReceiveSubmissionsFrom(?User $user): bool
{
if (! $user || ! $this->allow_submissions || ! $this->isPubliclyAccessible()) {
return false;
}
if ($this->type === self::TYPE_EDITORIAL) {
return false;
}
if ($this->collaboration_mode === self::COLLABORATION_CLOSED) {
return false;
}
return ! $this->hasActiveMember($user);
}
public function canReceiveCommentsFrom(?User $user): bool
{
return $user !== null && $this->allow_comments && $this->canBeViewedBy($user);
}
public function canBeSavedBy(?User $user): bool
{
return $user !== null && $this->allow_saves && $this->visibility === self::VISIBILITY_PUBLIC && ! $this->isOwnedBy($user);
}
public function isFeatureablePublicly(): bool
{
return $this->visibility === self::VISIBILITY_PUBLIC
&& $this->moderation_status === self::MODERATION_ACTIVE
&& (bool) $this->placement_eligibility
&& in_array($this->lifecycle_state, [self::LIFECYCLE_PUBLISHED, self::LIFECYCLE_FEATURED], true);
}
public function isSmart(): bool
{
return $this->mode === self::MODE_SMART;
}
public function canBeViewedBy(?User $viewer): bool
{
if ($viewer && ($this->isOwnedBy($viewer) || $this->hasActiveMember($viewer))) {
return true;
}
return $this->isPubliclyAccessible();
}
public function resolvedCoverArtwork(bool $publicOnly = false): ?Artwork
{
$cover = $this->relationLoaded('coverArtwork') ? $this->coverArtwork : $this->coverArtwork()->first();
if ($cover && (! $publicOnly || $this->artworkIsPubliclyVisible($cover))) {
return $cover;
}
$relation = $publicOnly ? 'publicArtworks' : 'artworks';
$artworks = $this->relationLoaded($relation)
? $this->getRelation($relation)
: $this->{$relation}()->limit(1)->get();
return $artworks->first();
}
public function syncArtworksCount(): void
{
$this->forceFill([
'artworks_count' => $this->isSmart()
? (int) $this->artworks_count
: $this->artworks()->count(),
])->save();
}
public function inSeries(): bool
{
return filled($this->series_key);
}
public function hasCanonicalTarget(): bool
{
return $this->canonical_collection_id !== null;
}
public function isPlacementEligible(): bool
{
return (bool) $this->placement_eligibility;
}
public function supportsAnalytics(): bool
{
return (bool) $this->analytics_enabled;
}
public function usesPremiumPresentation(): bool
{
return $this->presentation_style !== self::PRESENTATION_STANDARD
|| $this->emphasis_mode !== self::EMPHASIS_BALANCED
|| filled($this->theme_token);
}
private function artworkIsPubliclyVisible(Artwork $artwork): bool
{
return ! $artwork->trashed()
&& (bool) $artwork->is_public
&& (bool) $artwork->is_approved
&& $artwork->published_at !== null
&& $artwork->published_at->lte(now());
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
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;
use Illuminate\Database\Eloquent\SoftDeletes;
class CollectionComment extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'collection_id',
'user_id',
'parent_id',
'body',
'rendered_body',
'status',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionDailyStat extends Model
{
use HasFactory;
protected $fillable = [
'collection_id',
'stat_date',
'views_count',
'likes_count',
'follows_count',
'saves_count',
'comments_count',
'shares_count',
'submissions_count',
];
protected $casts = [
'stat_date' => 'date',
'views_count' => 'integer',
'likes_count' => 'integer',
'follows_count' => 'integer',
'saves_count' => 'integer',
'comments_count' => 'integer',
'shares_count' => 'integer',
'submissions_count' => 'integer',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionEntityLink extends Model
{
use HasFactory;
protected $fillable = [
'collection_id',
'linked_type',
'linked_id',
'relationship_type',
'metadata_json',
];
protected $casts = [
'metadata_json' => 'array',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionFollow extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'collection_id',
'user_id',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionHistory extends Model
{
use HasFactory;
protected $table = 'collection_history';
public $timestamps = false;
protected $fillable = [
'collection_id',
'actor_user_id',
'action_type',
'summary',
'before_json',
'after_json',
'created_at',
];
protected $casts = [
'before_json' => 'array',
'after_json' => 'array',
'created_at' => 'datetime',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionLike extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'collection_id',
'user_id',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionMember extends Model
{
use HasFactory;
protected $fillable = [
'collection_id',
'user_id',
'invited_by_user_id',
'role',
'status',
'note',
'invited_at',
'expires_at',
'accepted_at',
'revoked_at',
];
protected $casts = [
'invited_at' => 'datetime',
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by_user_id');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionMergeAction extends Model
{
use HasFactory;
protected $fillable = [
'source_collection_id',
'target_collection_id',
'action_type',
'actor_user_id',
'summary',
];
public function sourceCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'source_collection_id');
}
public function targetCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'target_collection_id');
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionProgramAssignment extends Model
{
use HasFactory;
protected $fillable = [
'collection_id',
'program_key',
'campaign_key',
'placement_scope',
'starts_at',
'ends_at',
'priority',
'notes',
'created_by_user_id',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'priority' => 'integer',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionQualitySnapshot extends Model
{
use HasFactory;
protected $fillable = [
'collection_id',
'snapshot_date',
'quality_score',
'health_score',
'metadata_completeness_score',
'freshness_score',
'engagement_score',
'readiness_score',
'flags_json',
];
protected $casts = [
'snapshot_date' => 'date',
'flags_json' => 'array',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionRecommendationSnapshot extends Model
{
use HasFactory;
protected $fillable = [
'collection_id',
'context_key',
'recommendation_score',
'rationale_json',
'snapshot_date',
];
protected $casts = [
'rationale_json' => 'array',
'snapshot_date' => 'date',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionSave extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'collection_id',
'user_id',
'created_at',
'last_viewed_at',
'save_context',
'save_context_meta_json',
];
protected $casts = [
'created_at' => 'datetime',
'last_viewed_at' => 'datetime',
'save_context_meta_json' => 'array',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
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 CollectionSavedList extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'title',
'slug',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(CollectionSavedListItem::class, 'saved_list_id')->orderBy('order_num');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionSavedListItem extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'saved_list_id',
'collection_id',
'order_num',
'created_at',
];
protected $casts = [
'order_num' => 'integer',
'created_at' => 'datetime',
];
public function list(): BelongsTo
{
return $this->belongsTo(CollectionSavedList::class, 'saved_list_id');
}
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionSavedNote extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'collection_id',
'note',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionSubmission extends Model
{
use HasFactory;
protected $fillable = [
'collection_id',
'artwork_id',
'user_id',
'message',
'status',
'reviewed_by_user_id',
'reviewed_at',
];
protected $casts = [
'reviewed_at' => 'datetime',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function reviewedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by_user_id');
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class CollectionSurfaceDefinition extends Model
{
use HasFactory;
protected $table = 'collection_surface_definitions';
protected $fillable = [
'surface_key',
'title',
'description',
'mode',
'rules_json',
'ranking_mode',
'max_items',
'is_active',
'starts_at',
'ends_at',
'fallback_surface_key',
];
protected $casts = [
'rules_json' => 'array',
'max_items' => 'integer',
'is_active' => 'boolean',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
];
public function placements(): HasMany
{
return $this->hasMany(CollectionSurfacePlacement::class, 'surface_key', 'surface_key');
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CollectionSurfacePlacement extends Model
{
use HasFactory;
protected $table = 'collection_surface_placements';
protected $fillable = [
'collection_id',
'surface_key',
'placement_type',
'priority',
'starts_at',
'ends_at',
'is_active',
'campaign_key',
'notes',
'created_by_user_id',
];
protected $casts = [
'priority' => 'integer',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'is_active' => 'boolean',
];
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}

301
app/Models/NovaCard.php Normal file
View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class NovaCard extends Model
{
use HasFactory, SoftDeletes;
public const FORMAT_SQUARE = 'square';
public const FORMAT_PORTRAIT = 'portrait';
public const FORMAT_STORY = 'story';
public const FORMAT_LANDSCAPE = 'landscape';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
public const STATUS_DRAFT = 'draft';
public const STATUS_PROCESSING = 'processing';
public const STATUS_PUBLISHED = 'published';
public const STATUS_HIDDEN = 'hidden';
public const STATUS_REJECTED = 'rejected';
public const MOD_PENDING = 'pending';
public const MOD_APPROVED = 'approved';
public const MOD_FLAGGED = 'flagged';
public const MOD_REJECTED = 'rejected';
protected $fillable = [
'uuid',
'user_id',
'category_id',
'title',
'slug',
'quote_text',
'quote_author',
'quote_source',
'description',
'format',
'project_json',
'schema_version',
'render_version',
'preview_path',
'preview_width',
'preview_height',
'background_type',
'background_image_id',
'original_card_id',
'root_card_id',
'template_id',
'visibility',
'status',
'moderation_status',
'featured',
'allow_download',
'allow_remix',
'views_count',
'shares_count',
'downloads_count',
'likes_count',
'favorites_count',
'saves_count',
'remixes_count',
'comments_count',
'challenge_entries_count',
'trending_score',
'featured_score',
'style_family',
'palette_family',
'density_score',
'editor_mode_last_used',
'allow_background_reuse',
'allow_export',
'original_creator_id',
'published_at',
'last_engaged_at',
'last_ranked_at',
'last_rendered_at',
];
protected $casts = [
'project_json' => 'array',
'schema_version' => 'integer',
'render_version' => 'integer',
'preview_width' => 'integer',
'preview_height' => 'integer',
'featured' => 'boolean',
'allow_download' => 'boolean',
'allow_remix' => 'boolean',
'views_count' => 'integer',
'shares_count' => 'integer',
'downloads_count' => 'integer',
'likes_count' => 'integer',
'favorites_count' => 'integer',
'saves_count' => 'integer',
'remixes_count' => 'integer',
'comments_count' => 'integer',
'challenge_entries_count' => 'integer',
'trending_score' => 'float',
'featured_score' => 'float',
'density_score' => 'integer',
'allow_background_reuse' => 'boolean',
'allow_export' => 'boolean',
'published_at' => 'datetime',
'last_engaged_at' => 'datetime',
'last_ranked_at' => 'datetime',
'last_rendered_at' => 'datetime',
];
protected static function booted(): void
{
static::creating(function (self $card): void {
if (! $card->uuid) {
$card->uuid = (string) Str::uuid();
}
});
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(NovaCardCategory::class, 'category_id');
}
public function template(): BelongsTo
{
return $this->belongsTo(NovaCardTemplate::class, 'template_id');
}
public function backgroundImage(): BelongsTo
{
return $this->belongsTo(NovaCardBackground::class, 'background_image_id');
}
public function originalCard(): BelongsTo
{
return $this->belongsTo(self::class, 'original_card_id');
}
public function rootCard(): BelongsTo
{
return $this->belongsTo(self::class, 'root_card_id');
}
public function originalCreator(): BelongsTo
{
return $this->belongsTo(User::class, 'original_creator_id');
}
public function remixes(): HasMany
{
return $this->hasMany(self::class, 'original_card_id');
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(NovaCardTag::class, 'nova_card_tag_relation', 'card_id', 'tag_id')
->withTimestamps();
}
public function reactions(): HasMany
{
return $this->hasMany(NovaCardReaction::class, 'card_id');
}
public function versions(): HasMany
{
return $this->hasMany(NovaCardVersion::class, 'card_id');
}
public function collectionItems(): HasMany
{
return $this->hasMany(NovaCardCollectionItem::class, 'card_id');
}
public function challengeEntries(): HasMany
{
return $this->hasMany(NovaCardChallengeEntry::class, 'card_id');
}
public function comments(): HasMany
{
return $this->hasMany(NovaCardComment::class, 'card_id');
}
public function scopePublished(Builder $query): Builder
{
return $query
->where('status', self::STATUS_PUBLISHED)
->where('moderation_status', '!=', self::MOD_REJECTED)
->whereIn('visibility', [self::VISIBILITY_PUBLIC, self::VISIBILITY_UNLISTED])
->whereNotNull('published_at');
}
public function scopePubliclyVisible(Builder $query): Builder
{
return $query
->published()
->where('visibility', self::VISIBILITY_PUBLIC)
->whereNotIn('moderation_status', [self::MOD_FLAGGED, self::MOD_REJECTED])
->where('status', self::STATUS_PUBLISHED);
}
public function scopeRising(Builder $query): Builder
{
return $query
->publiclyVisible()
->where('published_at', '>=', now()->subHours(96))
->where(function (Builder $q): void {
$q->where('saves_count', '>', 0)
->orWhere('remixes_count', '>', 0)
->orWhere('likes_count', '>', 1);
});
}
public function scopeFeaturedEditorial(Builder $query): Builder
{
return $query
->publiclyVisible()
->where(function (Builder $q): void {
$q->whereNotNull('featured_score')
->orWhere('featured', true);
});
}
public function previewUrl(): ?string
{
if (! $this->preview_path) {
return null;
}
return Storage::disk((string) config('nova_cards.storage.public_disk', 'public'))->url($this->preview_path);
}
public function ogPreviewUrl(): ?string
{
if (! $this->preview_path) {
return null;
}
$ogPath = preg_replace('/\.webp$/', '-og.jpg', $this->preview_path) ?: null;
if (! $ogPath) {
return $this->previewUrl();
}
return Storage::disk((string) config('nova_cards.storage.public_disk', 'public'))->url($ogPath);
}
public function publicUrl(): string
{
return route('cards.show', ['slug' => $this->slug, 'id' => $this->id]);
}
public function isOwnedBy(?User $user): bool
{
return $user !== null && (int) $user->id === (int) $this->user_id;
}
public function canBeViewedBy(?User $user): bool
{
if ($this->isOwnedBy($user)) {
return true;
}
if ($this->status !== self::STATUS_PUBLISHED) {
return false;
}
if ($this->moderation_status === self::MOD_REJECTED || $this->moderation_status === self::MOD_FLAGGED) {
return false;
}
return in_array($this->visibility, [self::VISIBILITY_PUBLIC, self::VISIBILITY_UNLISTED], true);
}
public function isRemix(): bool
{
return $this->original_card_id !== null;
}
public function canReceiveCommentsFrom(?User $user): bool
{
return $user !== null && $this->canBeViewedBy($user);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NovaCardAsset extends Model
{
protected $fillable = [
'asset_pack_id',
'asset_key',
'label',
'type',
'preview_image',
'data_json',
'official',
'active',
'order_num',
];
protected $casts = [
'data_json' => 'array',
'official' => 'boolean',
'active' => 'boolean',
'order_num' => 'integer',
];
public function pack(): BelongsTo
{
return $this->belongsTo(NovaCardAssetPack::class, 'asset_pack_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class NovaCardAssetPack extends Model
{
public const TYPE_ASSET = 'asset';
public const TYPE_TEMPLATE = 'template';
protected $fillable = [
'slug',
'name',
'description',
'type',
'preview_image',
'manifest_json',
'official',
'active',
'order_num',
];
protected $casts = [
'manifest_json' => 'array',
'official' => 'boolean',
'active' => 'boolean',
'order_num' => 'integer',
];
public function assets(): HasMany
{
return $this->hasMany(NovaCardAsset::class, 'asset_pack_id');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
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;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
class NovaCardBackground extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'original_path',
'processed_path',
'width',
'height',
'mime_type',
'file_size',
'sha256',
'visibility',
];
protected $casts = [
'width' => 'integer',
'height' => 'integer',
'file_size' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function cards(): HasMany
{
return $this->hasMany(NovaCard::class, 'background_image_id');
}
public function processedUrl(): ?string
{
if (! $this->processed_path) {
return null;
}
return Storage::disk((string) config('nova_cards.storage.public_disk', 'public'))->url($this->processed_path);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class NovaCardCategory extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'slug',
'name',
'description',
'active',
'order_num',
];
protected $casts = [
'active' => 'boolean',
'order_num' => 'integer',
];
public function cards(): HasMany
{
return $this->hasMany(NovaCard::class, 'category_id');
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class NovaCardChallenge extends Model
{
public const STATUS_DRAFT = 'draft';
public const STATUS_ACTIVE = 'active';
public const STATUS_COMPLETED = 'completed';
public const STATUS_ARCHIVED = 'archived';
protected $fillable = [
'user_id',
'slug',
'title',
'description',
'prompt',
'rules_json',
'status',
'official',
'featured',
'winner_card_id',
'entries_count',
'starts_at',
'ends_at',
];
protected $casts = [
'rules_json' => 'array',
'official' => 'boolean',
'featured' => 'boolean',
'entries_count' => 'integer',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function winnerCard(): BelongsTo
{
return $this->belongsTo(NovaCard::class, 'winner_card_id');
}
public function entries(): HasMany
{
return $this->hasMany(NovaCardChallengeEntry::class, 'challenge_id');
}
public function cards(): BelongsToMany
{
return $this->belongsToMany(NovaCard::class, 'nova_card_challenge_entries', 'challenge_id', 'card_id')
->withPivot(['user_id', 'status', 'note'])
->withTimestamps();
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NovaCardChallengeEntry extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_HIDDEN = 'hidden';
public const STATUS_REJECTED = 'rejected';
public const STATUS_SUBMITTED = 'submitted';
public const STATUS_FEATURED = 'featured';
public const STATUS_WINNER = 'winner';
protected $fillable = [
'challenge_id',
'card_id',
'user_id',
'status',
'note',
];
public function challenge(): BelongsTo
{
return $this->belongsTo(NovaCardChallenge::class, 'challenge_id');
}
public function card(): BelongsTo
{
return $this->belongsTo(NovaCard::class, 'card_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Route;
class NovaCardCollection extends Model
{
public const VISIBILITY_PRIVATE = 'private';
public const VISIBILITY_PUBLIC = 'public';
protected $fillable = [
'user_id',
'slug',
'name',
'description',
'visibility',
'official',
'featured',
'cards_count',
];
protected $casts = [
'official' => 'boolean',
'featured' => 'boolean',
'cards_count' => 'integer',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(NovaCardCollectionItem::class, 'collection_id');
}
public function cards(): BelongsToMany
{
return $this->belongsToMany(NovaCard::class, 'nova_card_collection_items', 'collection_id', 'card_id')
->withPivot(['note', 'sort_order'])
->withTimestamps();
}
public function isPubliclyVisible(): bool
{
return $this->official || $this->visibility === self::VISIBILITY_PUBLIC;
}
public function publicUrl(): ?string
{
if (! $this->exists || ! Route::has('cards.collections.show')) {
return null;
}
return route('cards.collections.show', [
'slug' => $this->slug,
'id' => $this->id,
]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NovaCardCollectionItem extends Model
{
protected $fillable = [
'collection_id',
'card_id',
'note',
'sort_order',
];
protected $casts = [
'sort_order' => 'integer',
];
public function collection(): BelongsTo
{
return $this->belongsTo(NovaCardCollection::class, 'collection_id');
}
public function card(): BelongsTo
{
return $this->belongsTo(NovaCard::class, 'card_id');
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
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;
use Illuminate\Database\Eloquent\SoftDeletes;
class NovaCardComment extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'card_id',
'user_id',
'parent_id',
'body',
'rendered_body',
'status',
];
public function card(): BelongsTo
{
return $this->belongsTo(NovaCard::class, 'card_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class NovaCardCreatorPreset extends Model
{
use HasFactory, SoftDeletes;
public const TYPE_STYLE = 'style';
public const TYPE_LAYOUT = 'layout';
public const TYPE_BACKGROUND = 'background';
public const TYPE_TYPOGRAPHY = 'typography';
public const TYPE_STARTER = 'starter';
public const TYPES = [
self::TYPE_STYLE,
self::TYPE_LAYOUT,
self::TYPE_BACKGROUND,
self::TYPE_TYPOGRAPHY,
self::TYPE_STARTER,
];
protected $fillable = [
'user_id',
'name',
'preset_type',
'config_json',
'is_default',
];
protected $casts = [
'config_json' => 'array',
'is_default' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class NovaCardExport extends Model
{
public const TYPE_PREVIEW = 'preview';
public const TYPE_HIRES = 'hires';
public const TYPE_SQUARE = 'square';
public const TYPE_STORY = 'story';
public const TYPE_WALLPAPER = 'wallpaper';
public const TYPE_OG = 'og';
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_READY = 'ready';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'card_id',
'user_id',
'export_type',
'status',
'output_path',
'width',
'height',
'format',
'options_json',
'ready_at',
'expires_at',
];
protected $casts = [
'options_json' => 'array',
'width' => 'integer',
'height' => 'integer',
'ready_at' => 'datetime',
'expires_at' => 'datetime',
];
public function card(): BelongsTo
{
return $this->belongsTo(NovaCard::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function outputUrl(): ?string
{
if (! $this->output_path) {
return null;
}
return Storage::disk((string) config('nova_cards.storage.public_disk', 'public'))->url($this->output_path);
}
public function isReady(): bool
{
return $this->status === self::STATUS_READY;
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NovaCardReaction extends Model
{
public const TYPE_LIKE = 'like';
public const TYPE_FAVORITE = 'favorite';
protected $fillable = [
'card_id',
'user_id',
'type',
];
public function card(): BelongsTo
{
return $this->belongsTo(NovaCard::class, 'card_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class NovaCardTag extends Model
{
use HasFactory;
protected $fillable = [
'slug',
'name',
];
public function cards(): BelongsToMany
{
return $this->belongsToMany(NovaCard::class, 'nova_card_tag_relation', 'tag_id', 'card_id')
->withTimestamps();
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class NovaCardTemplate extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'slug',
'name',
'description',
'preview_image',
'config_json',
'supported_formats',
'active',
'official',
'order_num',
];
protected $casts = [
'config_json' => 'array',
'supported_formats' => 'array',
'active' => 'boolean',
'official' => 'boolean',
'order_num' => 'integer',
];
public function cards(): HasMany
{
return $this->hasMany(NovaCard::class, 'template_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NovaCardVersion extends Model
{
protected $fillable = [
'card_id',
'user_id',
'version_number',
'label',
'snapshot_hash',
'snapshot_json',
];
protected $casts = [
'version_number' => 'integer',
'snapshot_json' => 'array',
];
public function card(): BelongsTo
{
return $this->belongsTo(NovaCard::class, 'card_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -5,6 +5,9 @@ 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;
use App\Models\ReportHistory;
class Report extends Model
{
@@ -17,10 +20,27 @@ class Report extends Model
'reason',
'details',
'status',
'moderator_note',
'last_moderated_by_id',
'last_moderated_at',
];
protected $casts = [
'last_moderated_at' => 'datetime',
];
public function reporter(): BelongsTo
{
return $this->belongsTo(User::class, 'reporter_id');
}
public function lastModeratedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'last_moderated_by_id');
}
public function historyEntries(): HasMany
{
return $this->hasMany(ReportHistory::class)->latest('id');
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReportHistory extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'report_history';
protected $fillable = [
'report_id',
'actor_user_id',
'action_type',
'summary',
'note',
'before_json',
'after_json',
'created_at',
];
protected $casts = [
'before_json' => 'array',
'after_json' => 'array',
'created_at' => 'datetime',
];
public function report(): BelongsTo
{
return $this->belongsTo(Report::class);
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
}

View File

@@ -15,6 +15,7 @@ use App\Models\Message;
use App\Models\Notification;
use App\Models\Achievement;
use App\Models\UserAchievement;
use App\Models\UserActivity;
use App\Models\UserXpLog;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -25,7 +26,10 @@ use Laravel\Scout\Searchable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, SoftDeletes, Searchable;
use HasFactory, Notifiable, SoftDeletes;
use Searchable {
Searchable::bootSearchable as private bootScoutSearchable;
}
/**
* The attributes that are mass assignable.
@@ -59,6 +63,7 @@ class User extends Authenticatable
'rank',
'password',
'role',
'nova_featured_creator',
'allow_messages_from',
];
@@ -81,6 +86,7 @@ class User extends Authenticatable
{
return [
'email_verified_at' => 'datetime',
'last_visit_at' => 'datetime',
'last_verification_sent_at' => 'datetime',
'verification_send_window_started_at' => 'datetime',
'verification_send_count_24h' => 'integer',
@@ -98,16 +104,32 @@ class User extends Authenticatable
'xp' => 'integer',
'level' => 'integer',
'rank' => 'string',
'nova_featured_creator' => 'boolean',
'password' => 'hashed',
'allow_messages_from' => 'string',
];
}
public function novaCards(): HasMany
{
return $this->hasMany(NovaCard::class);
}
public function artworks(): HasMany
{
return $this->hasMany(Artwork::class);
}
public function collections(): HasMany
{
return $this->hasMany(Collection::class)->latest('updated_at');
}
public function savedCollectionLists(): HasMany
{
return $this->hasMany(CollectionSavedList::class, 'user_id')->orderBy('title');
}
public function socialAccounts(): HasMany
{
return $this->hasMany(SocialAccount::class);
@@ -170,6 +192,11 @@ class User extends Authenticatable
return $this->hasMany(UserAchievement::class, 'user_id');
}
public function userActivities(): HasMany
{
return $this->hasMany(UserActivity::class, 'user_id');
}
public function achievements(): BelongsToMany
{
return $this->belongsToMany(Achievement::class, 'user_achievements', 'user_id', 'achievement_id')
@@ -298,6 +325,14 @@ class User extends Authenticatable
// ─── Meilisearch ──────────────────────────────────────────────────────────
/**
* User indexing is handled explicitly through IndexUserJob so unrelated
* model writes do not enqueue Scout sync jobs implicitly.
*/
protected static function bootSearchable(): void
{
}
/**
* Only index active users (not soft-deleted, is_active = true).
*/

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserActivity extends Model
{
protected $table = 'user_activities';
public $timestamps = false;
public const CREATED_AT = 'created_at';
public const UPDATED_AT = null;
public const TYPE_UPLOAD = 'upload';
public const TYPE_COMMENT = 'comment';
public const TYPE_REPLY = 'reply';
public const TYPE_LIKE = 'like';
public const TYPE_FAVOURITE = 'favourite';
public const TYPE_FOLLOW = 'follow';
public const TYPE_ACHIEVEMENT = 'achievement';
public const TYPE_FORUM_POST = 'forum_post';
public const TYPE_FORUM_REPLY = 'forum_reply';
public const ENTITY_ARTWORK = 'artwork';
public const ENTITY_ARTWORK_COMMENT = 'artwork_comment';
public const ENTITY_USER = 'user';
public const ENTITY_ACHIEVEMENT = 'achievement';
public const ENTITY_FORUM_THREAD = 'forum_thread';
public const ENTITY_FORUM_POST = 'forum_post';
protected $fillable = [
'user_id',
'type',
'entity_type',
'entity_id',
'meta',
'created_at',
'hidden_at',
'hidden_by',
'hidden_reason',
'flagged_at',
'flagged_by',
'flag_reason',
];
protected function casts(): array
{
return [
'user_id' => 'integer',
'entity_id' => 'integer',
'meta' => 'array',
'created_at' => 'datetime',
'hidden_at' => 'datetime',
'hidden_by' => 'integer',
'flagged_at' => 'datetime',
'flagged_by' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserNegativeSignal extends Model
{
use HasFactory;
protected $table = 'user_negative_signals';
protected $guarded = [];
protected $casts = [
'meta' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function tag(): BelongsTo
{
return $this->belongsTo(Tag::class);
}
}