Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\UserAchievement;
class Achievement extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
'icon',
'xp_reward',
'type',
'condition_type',
'condition_value',
];
protected function casts(): array
{
return [
'xp_reward' => 'integer',
'condition_value' => 'integer',
];
}
public function userAchievements(): HasMany
{
return $this->hasMany(UserAchievement::class, 'achievement_id');
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_achievements', 'achievement_id', 'user_id')
->withPivot('unlocked_at');
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Unified activity feed event.
*
* Types: upload | comment | favorite | award | follow
* target_type: artwork | story | user
*
* @property int $id
* @property int $actor_id
* @property string $type
* @property string $target_type
* @property int $target_id
* @property array|null $meta
* @property \Illuminate\Support\Carbon $created_at
*/
class ActivityEvent extends Model
{
protected $table = 'activity_events';
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $fillable = [
'actor_id',
'type',
'target_type',
'target_id',
'meta',
];
protected $casts = [
'actor_id' => 'integer',
'target_id' => 'integer',
'meta' => 'array',
'created_at' => 'datetime',
];
// ── Event type constants ──────────────────────────────────────────────────
const TYPE_UPLOAD = 'upload';
const TYPE_COMMENT = 'comment';
const TYPE_FAVORITE = 'favorite';
const TYPE_AWARD = 'award';
const TYPE_FOLLOW = 'follow';
const TARGET_ARTWORK = 'artwork';
const TARGET_STORY = 'story';
const TARGET_USER = 'user';
// ── Relations ─────────────────────────────────────────────────────────────
/** The user who performed the action */
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
// ── Factory helpers ───────────────────────────────────────────────────────
public static function record(
int $actorId,
string $type,
string $targetType,
int $targetId,
array $meta = []
): static {
$event = static::create([
'actor_id' => $actorId,
'type' => $type,
'target_type' => $targetType,
'target_id' => $targetId,
'meta' => $meta ?: null,
'created_at' => now(),
]);
// Ensure created_at is available on the returned instance
// ($timestamps = false means Eloquent doesn't auto-populate it)
if ($event->created_at === null) {
$event->created_at = now();
}
return $event;
}
}

View File

@@ -0,0 +1,581 @@
<?php
namespace App\Models;
use App\Models\ArtworkContributor;
use App\Models\Group;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
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 App\Services\ThumbnailService;
use Illuminate\Support\Facades\DB;
use Laravel\Scout\Searchable;
/**
* App\Models\Artwork
*
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkTranslation[] $translations
* @property-read ArtworkStats $stats
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkComment[] $comments
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkDownload[] $downloads
*/
class Artwork extends Model
{
use HasFactory, SoftDeletes, Searchable;
public const PUBLISHED_AS_USER = 'user';
public const PUBLISHED_AS_GROUP = 'group';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
protected $table = 'artworks';
protected $fillable = [
'user_id',
'group_id',
'uploaded_by_user_id',
'primary_author_user_id',
'published_as_type',
'published_as_id',
'title',
'slug',
'description',
'file_name',
'file_path',
'hash',
'file_ext',
'thumb_ext',
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
'file_size',
'mime_type',
'width',
'height',
'is_public',
'visibility',
'is_approved',
'is_mature',
'maturity_level',
'maturity_source',
'maturity_status',
'maturity_ai_score',
'maturity_ai_labels',
'maturity_ai_label',
'maturity_ai_confidence',
'maturity_ai_model',
'maturity_ai_threshold_used',
'maturity_ai_analysis_time_ms',
'maturity_ai_action_hint',
'maturity_ai_advisory',
'maturity_ai_status',
'maturity_ai_detected_at',
'maturity_declared_at',
'maturity_flagged_at',
'maturity_flag_reason',
'maturity_reviewed_by',
'maturity_reviewed_at',
'maturity_reviewer_note',
'maturity_mismatch_count',
'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',
'version_updated_at',
'requires_reapproval',
// Scheduled publishing
'publish_at',
'artwork_status',
'artwork_timezone',
];
protected $casts = [
'is_public' => 'boolean',
'visibility' => 'string',
'is_approved' => 'boolean',
'is_mature' => 'boolean',
'maturity_level' => 'string',
'maturity_source' => 'string',
'maturity_status' => 'string',
'maturity_ai_score' => 'float',
'maturity_ai_labels' => 'array',
'maturity_ai_label' => 'string',
'maturity_ai_confidence' => 'float',
'maturity_ai_model' => 'string',
'maturity_ai_threshold_used' => 'float',
'maturity_ai_analysis_time_ms' => 'integer',
'maturity_ai_action_hint' => 'string',
'maturity_ai_advisory' => 'string',
'maturity_ai_status' => 'string',
'maturity_ai_detected_at' => 'datetime',
'maturity_declared_at' => 'datetime',
'maturity_flagged_at' => 'datetime',
'maturity_reviewed_at' => 'datetime',
'maturity_mismatch_count' => 'integer',
'has_missing_thumbnails' => 'boolean',
'published_at' => 'datetime',
'missing_thumbnail_variants_json' => 'array',
'thumbnails_checked_at' => 'datetime',
'published_as_type' => 'string',
'published_as_id' => 'integer',
'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',
];
/**
* Thumbnail sizes and their options.
* Keys are the size dir used in the CDN URL.
*/
protected const THUMB_SIZES = [
'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'],
'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'],
'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'],
'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'],
];
/**
* Build the thumbnail URL for this artwork.
* Returns null when no hash or thumb_ext is available.
*/
public function thumbUrl(string $size = 'md'): ?string
{
if (empty($this->hash) || empty($this->thumb_ext)) {
return null;
}
$sizeKey = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
return ThumbnailService::fromHash($this->hash, $this->thumb_ext, $sizeKey);
}
/**
* Accessor for `$art->thumb` used in legacy views (default medium size).
*/
public function getThumbAttribute(): string
{
return $this->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp';
}
/**
* Accessor for `$art->thumb_url` used in some views.
*/
public function getThumbUrlAttribute(): ?string
{
return $this->thumbUrl('md');
}
/**
* Backwards-compatible alias used by legacy views: `$art->thumbnail_url`.
* Prefer CDN thumbnail URL, then legacy `thumb` accessor, finally a placeholder.
*/
public function getThumbnailUrlAttribute(): ?string
{
$url = $this->getThumbUrlAttribute();
if (!empty($url)) return $url;
$thumb = $this->getThumbAttribute();
if (!empty($thumb)) return $thumb;
return '/images/placeholder.jpg';
}
/**
* Provide a responsive `srcset` for legacy views.
*/
public function getThumbSrcsetAttribute(): ?string
{
if (empty($this->hash) || empty($this->thumb_ext)) return null;
$sm = $this->thumbUrl('sm');
$md = $this->thumbUrl('md');
if (!$sm || !$md) return null;
return $sm . ' 320w, ' . $md . ' 600w';
}
// Relations
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function maturityAuditFinding(): HasOne
{
return $this->hasOne(ArtworkMaturityAuditFinding::class);
}
public function uploadedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by_user_id');
}
public function primaryAuthor(): BelongsTo
{
return $this->belongsTo(User::class, 'primary_author_user_id');
}
public function contributors(): HasMany
{
return $this->hasMany(ArtworkContributor::class)->orderBy('sort_order');
}
public function isPublishedByGroup(): bool
{
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP;
}
public function publishedAsType(): string
{
if (in_array($this->published_as_type, [self::PUBLISHED_AS_USER, self::PUBLISHED_AS_GROUP], true)) {
return (string) $this->published_as_type;
}
return (int) ($this->group_id ?? 0) > 0 ? self::PUBLISHED_AS_GROUP : self::PUBLISHED_AS_USER;
}
public function publishedAsId(): int
{
if ((int) ($this->published_as_id ?? 0) > 0) {
return (int) $this->published_as_id;
}
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP
? (int) ($this->group_id ?? 0)
: (int) $this->user_id;
}
public function translations(): HasMany
{
return $this->hasMany(ArtworkTranslation::class);
}
public function stats(): HasOne
{
return $this->hasOne(ArtworkStats::class, 'artwork_id');
}
public function categories(): BelongsToMany
{
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')
->withPivot(['source', 'confidence']);
}
public function comments(): HasMany
{
return $this->hasMany(ArtworkComment::class);
}
public function downloads(): HasMany
{
return $this->hasMany(ArtworkDownload::class);
}
public function embeddings(): HasMany
{
return $this->hasMany(ArtworkEmbedding::class, 'artwork_id');
}
public function similarities(): HasMany
{
return $this->hasMany(ArtworkSimilarity::class, 'artwork_id');
}
public function features(): HasMany
{
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
}
/** All favourite pivot rows for this artwork. */
public function favourites(): HasMany
{
return $this->hasMany(ArtworkFavourite::class, 'artwork_id');
}
/** Users who have favourited this artwork (many-to-many shortcut). */
public function favouritedBy(): BelongsToMany
{
return $this->belongsToMany(User::class, 'artwork_favourites', 'artwork_id', 'user_id')
->withPivot('legacy_id')
->withTimestamps();
}
public function awards(): HasMany
{
return $this->hasMany(ArtworkAward::class);
}
public function medals(): HasMany
{
return $this->hasMany(ArtworkMedal::class, 'artwork_id');
}
public function outgoingEvolutionRelations(): HasMany
{
return $this->hasMany(ArtworkRelation::class, 'source_artwork_id')
->orderBy('sort_order')
->orderBy('id');
}
public function incomingEvolutionRelations(): HasMany
{
return $this->hasMany(ArtworkRelation::class, 'target_artwork_id')
->orderByDesc('updated_at')
->orderByDesc('id');
}
/** All file versions for this artwork (oldest first). */
public function versions(): HasMany
{
return $this->hasMany(ArtworkVersion::class)->orderBy('version_number');
}
/** The currently active version record. */
public function currentVersion(): BelongsTo
{
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);
}
public function medalStats(): HasOne
{
return $this->hasOne(ArtworkMedalStat::class, 'artwork_id');
}
/**
* Build the Meilisearch document for this artwork.
* Includes all fields required for search, filtering, sorting, and display.
*/
public function toSearchableArray(): array
{
$this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']);
$stat = $this->stats;
$awardStat = $this->awardStat;
$publishedSortAt = $this->published_at ?? $this->created_at;
// Orientation derived from pixel dimensions
$orientation = 'square';
if ($this->width && $this->height) {
if ($this->width > $this->height) {
$orientation = 'landscape';
} elseif ($this->height > $this->width) {
$orientation = 'portrait';
}
}
// Resolution string e.g. "1920x1080"
$resolution = ($this->width && $this->height)
? $this->width . 'x' . $this->height
: '';
// Primary category slug (first attached category)
$primaryCategory = $this->categories->first();
$category = $primaryCategory?->slug ?? '';
$content_type = $primaryCategory?->contentType?->slug ?? '';
// Tag slugs array
$tags = $this->tags->pluck('slug')->values()->all();
return [
'id' => $this->id,
'slug' => $this->slug,
'title' => $this->title,
'description' => (string) ($this->description ?? ''),
'author_id' => $this->publishedAsId(),
'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase',
'published_as_type' => $this->publishedAsType(),
'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),
'likes' => (int) ($stat?->favorites ?? 0),
'views' => (int) ($stat?->views ?? 0),
'created_at' => $publishedSortAt?->toDateString() ?? '',
'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0,
'is_public' => (bool) $this->is_public,
'is_approved' => (bool) $this->is_approved,
'is_mature' => (bool) $this->is_mature,
'is_mature_effective' => (bool) ($this->is_mature || $this->maturity_level === 'mature' || $this->maturity_status === 'suspected'),
'maturity_level' => (string) ($this->maturity_level ?? 'safe'),
'maturity_status' => (string) ($this->maturity_status ?? 'clear'),
'has_missing_thumbnails' => (bool) ($this->has_missing_thumbnails ?? false),
'missing_thumbnail_rank' => (int) (($this->has_missing_thumbnails ?? false) ? 1 : 0),
// ── 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),
'awards_received_count' => (int) ($awardStat?->score_total ?? 0),
'awards_score_7d' => (int) ($awardStat?->score_7d ?? 0),
'awards_score_30d' => (int) ($awardStat?->score_30d ?? 0),
'downloads_count' => (int) ($stat?->downloads ?? 0),
// ── Ranking V2 fields ───────────────────────────────────────────────
'ranking_score' => (float) ($stat?->ranking_score ?? 0),
'engagement_velocity' => (float) ($stat?->engagement_velocity ?? 0),
'shares_count' => (int) ($stat?->shares_count ?? 0),
'comments_count' => (int) ($stat?->comments_count ?? 0),
// ── Rising / Heat fields ────────────────────────────────────────────────────
'heat_score' => (float) ($stat?->heat_score ?? 0),
'awards' => [
'gold' => $awardStat?->gold_count ?? 0,
'silver' => $awardStat?->silver_count ?? 0,
'bronze' => $awardStat?->bronze_count ?? 0,
'score' => $awardStat?->score_total ?? 0,
'score_7d' => $awardStat?->score_7d ?? 0,
'score_30d' => $awardStat?->score_30d ?? 0,
],
];
}
// Scopes
public function scopePublic(Builder $query): Builder
{
// Compose approved() so behavior is consistent and composable
$table = $this->getTable();
return $query->approved()->where("{$table}.is_public", true);
}
public function scopeApproved(Builder $query): Builder
{
// Respect soft deletes and mark approved content
$table = $this->getTable();
return $query->whereNull("{$table}.deleted_at")->where("{$table}.is_approved", true);
}
public function scopePublished(Builder $query): Builder
{
// Respect soft deletes and only include published items up to now
$table = $this->getTable();
return $query->whereNull("{$table}.deleted_at")
->whereNotNull("{$table}.published_at")
->where("{$table}.published_at", '<=', now());
}
public function scopeSafeForGeneralAudience(Builder $query): Builder
{
$table = $this->getTable();
return $query
->whereRaw('COALESCE(' . $table . '.is_mature, 0) = 0')
->whereRaw("COALESCE(" . $table . ".maturity_status, 'clear') != ?", ['suspected']);
}
public function scopeWithoutMissingThumbnails(Builder $query): Builder
{
$table = $this->getTable();
return $query->where(function (Builder $thumbnailQuery) use ($table): void {
$thumbnailQuery->whereNull("{$table}.has_missing_thumbnails")
->orWhere("{$table}.has_missing_thumbnails", false);
});
}
public function scopeOrderMissingThumbnailsLast(Builder $query): Builder
{
$table = $this->getTable();
return $query->orderByRaw("CASE WHEN {$table}.has_missing_thumbnails = 1 THEN 1 ELSE 0 END ASC");
}
public function getRouteKeyName(): string
{
return 'slug';
}
protected static function booted(): void
{
static::saving(function (Artwork $artwork): void {
if ($artwork->published_at === null) {
return;
}
$publishedAt = $artwork->published_at->copy();
if ($artwork->created_at === null || ! $artwork->created_at->equalTo($publishedAt)) {
$artwork->created_at = $publishedAt;
}
});
static::deleting(function (Artwork $artwork): void {
if (! method_exists($artwork, 'isForceDeleting') || ! $artwork->isForceDeleting()) {
return;
}
// Cleanup pivot rows and decrement usage counts on force delete.
$tagIds = DB::table('artwork_tag')->where('artwork_id', $artwork->id)->pluck('tag_id')->all();
if ($tagIds === []) {
return;
}
DB::table('artwork_tag')->where('artwork_id', $artwork->id)->delete();
DB::table('tags')
->whereIn('id', $tagIds)
->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]);
});
}
}

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);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArtworkAward extends Model
{
protected $table = 'artwork_medals';
protected $fillable = [
'artwork_id',
'user_id',
'medal_type',
'medal',
'weight',
];
protected $casts = [
'artwork_id' => 'integer',
'user_id' => 'integer',
'weight' => 'integer',
];
public const MEDALS = ['gold', 'silver', 'bronze'];
public const WEIGHTS = [
'gold' => 5,
'silver' => 3,
'bronze' => 1,
];
public static function weightFor(string $medal): int
{
return (int) config('artwork_medals.weights.' . $medal, self::WEIGHTS[$medal] ?? 0);
}
/**
* @return array<string, int>
*/
public static function weights(): array
{
return collect(self::MEDALS)
->mapWithKeys(fn (string $medal): array => [$medal => self::weightFor($medal)])
->all();
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function getMedalAttribute(): ?string
{
return $this->attributes['medal_type'] ?? null;
}
public function setMedalAttribute(?string $value): void
{
$this->attributes['medal_type'] = $value;
}
}

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;
class ArtworkAwardStat extends Model
{
protected $table = 'artwork_medal_stats';
public $primaryKey = 'artwork_id';
public $incrementing = false;
public $timestamps = true;
protected $fillable = [
'artwork_id',
'gold_count',
'silver_count',
'bronze_count',
'score_total',
'score_7d',
'score_30d',
'last_medaled_at',
'created_at',
'updated_at',
];
protected $casts = [
'artwork_id' => 'integer',
'gold_count' => 'integer',
'silver_count' => 'integer',
'bronze_count' => 'integer',
'score_total' => 'integer',
'score_7d' => 'integer',
'score_30d' => 'integer',
'last_medaled_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1 @@
<?php

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* App\Models\ArtworkComment
*
* @property int $id
* @property int $artwork_id
* @property int $user_id
* @property string|null $content Legacy plain-text column
* @property string|null $raw_content User-submitted Markdown
* @property string|null $rendered_content Cached sanitized HTML
* @property bool $is_approved
* @property-read Artwork $artwork
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection|CommentReaction[] $reactions
*/
class ArtworkComment extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'artwork_comments';
protected $fillable = [
'legacy_id',
'artwork_id',
'user_id',
'parent_id',
'content',
'raw_content',
'rendered_content',
'is_approved',
];
protected $casts = [
'is_approved' => 'boolean',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::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');
}
/**
* Recursively eager-load approved replies (tree structure).
*/
public function approvedReplies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')
->where('is_approved', true)
->orderBy('created_at')
->with(['user.profile', 'approvedReplies']);
}
public function reactions(): HasMany
{
return $this->hasMany(CommentReaction::class, 'comment_id');
}
/**
* Return the best available rendered content for display.
* Falls back to escaping raw legacy content if rendering isn't done yet.
*/
public function getDisplayHtml(): string
{
if ($this->rendered_content !== null) {
return $this->rendered_content;
}
// Lazy render: raw_content takes priority over legacy content
$raw = $this->raw_content ?? $this->content ?? '';
return \App\Services\ContentSanitizer::render($raw);
}
}

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 ArtworkContributor extends Model
{
use HasFactory;
protected $fillable = [
'artwork_id',
'user_id',
'credit_role',
'is_primary',
'sort_order',
];
protected $casts = [
'is_primary' => 'boolean',
'sort_order' => 'integer',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkDownload
*
* @property-read Artwork $artwork
* @property-read User|null $user
*/
class ArtworkDownload extends Model
{
protected $table = 'artwork_downloads';
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $fillable = [
'artwork_id',
'user_id',
'ip',
'ip_address',
'user_agent',
'referer',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkEmbedding extends Model
{
protected $table = 'artwork_embeddings';
protected $fillable = [
'artwork_id',
'model',
'model_version',
'algo_version',
'dim',
'embedding_json',
'source_hash',
'is_normalized',
'generated_at',
'meta',
];
protected $casts = [
'is_normalized' => 'boolean',
'generated_at' => 'datetime',
'meta' => 'array',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Represents a user's "favourite" bookmark on an artwork.
*
* @property int $id
* @property int $user_id
* @property int $artwork_id
* @property int|null $legacy_id Original favourite_id from the old site
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*
* @property-read User $user
* @property-read Artwork $artwork
*/
class ArtworkFavourite extends Model
{
protected $table = 'artwork_favourites';
protected $fillable = [
'user_id',
'artwork_id',
'legacy_id',
];
protected $casts = [
'user_id' => 'integer',
'artwork_id' => 'integer',
'legacy_id' => 'integer',
];
// ── Relations ──────────────────────────────────────────────────────────
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ArtworkFeature extends Model
{
use SoftDeletes;
protected $table = 'artwork_features';
protected $fillable = [
'artwork_id',
'type',
'featured_at',
'expires_at',
'priority',
'label',
'note',
'is_active',
'force_hero',
'created_by',
];
protected $casts = [
'featured_at' => 'datetime',
'expires_at' => 'datetime',
'priority' => 'integer',
'is_active' => 'boolean',
'force_hero' => 'boolean',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,61 @@
<?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;
final class ArtworkMaturityAuditFinding extends Model
{
use HasFactory;
public const STATUS_OPEN = 'open';
public const STATUS_REVIEWED = 'reviewed';
public const STATUS_CLEARED = 'cleared';
protected $fillable = [
'artwork_id',
'status',
'thumbnail_variant',
'ai_label',
'ai_confidence',
'ai_score',
'ai_labels',
'ai_model',
'ai_threshold_used',
'ai_analysis_time_ms',
'ai_action_hint',
'ai_status',
'ai_advisory',
'detected_at',
'last_scanned_at',
'resolution_action',
'resolution_note',
'resolved_by',
'resolved_at',
];
protected $casts = [
'ai_confidence' => 'float',
'ai_score' => 'float',
'ai_labels' => 'array',
'ai_threshold_used' => 'float',
'ai_analysis_time_ms' => 'integer',
'detected_at' => 'datetime',
'last_scanned_at' => 'datetime',
'resolved_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function resolver(): BelongsTo
{
return $this->belongsTo(User::class, 'resolved_by');
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models;
class ArtworkMedal extends ArtworkAward
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models;
class ArtworkMedalStat extends ArtworkAwardStat
{
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkMetricSnapshotHourly
*
* Stores hourly totals for artwork metrics. Deltas are computed by
* subtracting the previous hour's snapshot from the current one.
*
* @property int $id
* @property int $artwork_id
* @property \Illuminate\Support\Carbon $bucket_hour
* @property int $views_count
* @property int $downloads_count
* @property int $favourites_count
* @property int $comments_count
* @property int $shares_count
* @property \Illuminate\Support\Carbon $created_at
*/
class ArtworkMetricSnapshotHourly extends Model
{
protected $table = 'artwork_metric_snapshots_hourly';
public $timestamps = false;
protected $fillable = [
'artwork_id',
'bucket_hour',
'views_count',
'downloads_count',
'favourites_count',
'comments_count',
'shares_count',
];
protected $casts = [
'bucket_hour' => 'datetime',
'views_count' => 'integer',
'downloads_count' => 'integer',
'favourites_count' => 'integer',
'comments_count' => 'integer',
'shares_count' => 'integer',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $artwork_id
* @property int $user_id
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
*/
class ArtworkReaction extends Model
{
public $timestamps = false;
protected $table = 'artwork_reactions';
protected $fillable = ['artwork_id', 'user_id', 'reaction'];
protected $casts = [
'artwork_id' => 'integer',
'user_id' => 'integer',
'created_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::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;
final class ArtworkRelation extends Model
{
use HasFactory;
public const TYPE_REMAKE_OF = 'remake_of';
public const TYPE_REMASTER_OF = 'remaster_of';
public const TYPE_REVISION_OF = 'revision_of';
public const TYPE_INSPIRED_BY = 'inspired_by';
public const TYPE_VARIATION_OF = 'variation_of';
protected $fillable = [
'source_artwork_id',
'target_artwork_id',
'relation_type',
'note',
'sort_order',
'created_by_user_id',
];
protected $casts = [
'sort_order' => 'integer',
];
public function sourceArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'source_artwork_id');
}
public function targetArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'target_artwork_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}

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;
final class ArtworkSimilarity extends Model
{
protected $table = 'artwork_similarities';
protected $fillable = [
'artwork_id',
'similar_artwork_id',
'model',
'model_version',
'algo_version',
'rank',
'score',
'generated_at',
'meta',
];
protected $casts = [
'rank' => 'integer',
'score' => 'float',
'generated_at' => 'datetime',
'meta' => 'array',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
public function similarArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'similar_artwork_id');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkStats
*
* @property-read Artwork $artwork
*/
class ArtworkStats extends Model
{
protected $table = 'artwork_stats';
protected $primaryKey = 'artwork_id';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'artwork_id',
'views',
'downloads',
'favorites',
'rating_avg',
'rating_count',
// V2 ranking columns
'comments_count',
'shares_count',
'ranking_score',
'engagement_velocity',
'shares_24h',
'comments_24h',
'favourites_24h',
// Rising / Heat columns
'heat_score',
'heat_score_updated_at',
'views_1h',
'favourites_1h',
'comments_1h',
'shares_1h',
'downloads_1h',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkTranslation
*
* @property-read Artwork $artwork
*/
class ArtworkTranslation extends Model
{
use SoftDeletes;
protected $table = 'artwork_translations';
protected $fillable = [
'artwork_id',
'locale',
'title',
'description',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkVersion
*
* Represents a single immutable snapshot of an artwork file.
* Only one version per artwork is marked is_current = true at a time.
*/
class ArtworkVersion extends Model
{
protected $table = 'artwork_versions';
protected $fillable = [
'artwork_id',
'version_number',
'file_path',
'file_hash',
'width',
'height',
'file_size',
'change_note',
'is_current',
];
protected $casts = [
'is_current' => 'boolean',
'version_number' => 'integer',
'width' => 'integer',
'height' => 'integer',
'file_size' => 'integer',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

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;
/**
* App\Models\ArtworkVersionEvent
*
* Audit log for all version-related actions (create_version, restore_version).
*/
class ArtworkVersionEvent extends Model
{
protected $table = 'artwork_version_events';
protected $fillable = [
'artwork_id',
'user_id',
'action',
'version_id',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Blog post model for the /blog section.
*
* @property int $id
* @property string $slug
* @property string $title
* @property string $body HTML or Markdown content
* @property string|null $excerpt
* @property int|null $author_id
* @property string|null $featured_image
* @property string|null $meta_title
* @property string|null $meta_description
* @property bool $is_published
* @property \Carbon\Carbon|null $published_at
*/
class BlogPost extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'blog_posts';
protected $fillable = [
'slug',
'title',
'body',
'excerpt',
'author_id',
'featured_image',
'meta_title',
'meta_description',
'is_published',
'published_at',
];
protected $casts = [
'is_published' => 'boolean',
'published_at' => 'datetime',
];
// ── Relations ────────────────────────────────────────────────────────
public function author()
{
return $this->belongsTo(User::class, 'author_id');
}
// ── Scopes ───────────────────────────────────────────────────────────
public function scopePublished($query)
{
return $query->where('is_published', true)
->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()));
}
// ── Accessors ────────────────────────────────────────────────────────
public function getUrlAttribute(): string
{
return url('/blog/' . $this->slug);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\BugReport
*/
final class BugReport extends Model
{
protected $fillable = [
'user_id',
'subject',
'description',
'ip_address',
'user_agent',
'status',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany, BelongsToMany, HasOne};
use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Model
{
use SoftDeletes;
protected $fillable = [
'content_type_id','parent_id','name','slug',
'description','image','is_active','sort_order'
];
protected $casts = ['is_active' => 'boolean'];
public function getNameAttribute(?string $value): ?string
{
return self::decodeHtmlEntities($value);
}
public function setNameAttribute(?string $value): void
{
$normalized = self::decodeHtmlEntities($value);
$this->attributes['name'] = $normalized !== null ? trim($normalized) : null;
}
public function getDescriptionAttribute(?string $value): ?string
{
return self::decodeHtmlEntities($value);
}
public function setDescriptionAttribute(?string $value): void
{
$this->attributes['description'] = self::decodeHtmlEntities($value);
}
/**
* Ensure slug is always lowercase and valid before saving.
*/
protected static function boot()
{
parent::boot();
static::saving(function (Category $model) {
if (isset($model->slug)) {
$model->slug = strtolower($model->slug);
if (!preg_match('/^[a-z0-9-]+$/', $model->slug)) {
throw new \InvalidArgumentException('Category slug must be lowercase and contain only a-z, 0-9, and dashes.');
}
}
});
}
public function contentType(): BelongsTo
{
return $this->belongsTo(ContentType::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(Category::class, 'parent_id')
->orderBy('sort_order')->orderBy('name');
}
public function descendants(): HasMany
{
return $this->children()->with('descendants');
}
public function seo(): HasOne
{
return $this->hasOne(CategorySeo::class);
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'artwork_category');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeRoots($query)
{
return $query->whereNull('parent_id');
}
public function getFullSlugPathAttribute(): string
{
return $this->parent
? $this->parent->full_slug_path . '/' . $this->slug
: $this->slug;
}
/**
* Get the full public URL for this category (authoritative spec).
* Example: /photography/abstract/dark
*/
public function getUrlAttribute(): string
{
$contentTypeSlug = strtolower($this->contentType->slug);
$path = strtolower($this->full_slug_path);
return '/' . $contentTypeSlug . ($path ? '/' . $path : '');
}
/**
* Get the canonical URL for SEO (authoritative spec).
* Example: https://skinbase.org/photography/abstract/dark
*/
public function getCanonicalUrlAttribute(): string
{
return 'https://skinbase.org' . $this->url;
}
public function getBreadcrumbsAttribute(): array
{
return $this->parent
? array_merge($this->parent->breadcrumbs, [$this])
: [$this];
}
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Resolve a category by a content-type slug and a category path (e.g. "audio/winamp").
* This will locate the category with the final slug and verify its parent chain
* matches the provided path and that the category belongs to the given content type.
*
* @param string $contentTypeSlug
* @param string|array $categoryPath
* @return Category|null
*/
public static function findByPath(string $contentTypeSlug, $categoryPath): ?Category
{
$parts = is_array($categoryPath)
? array_values(array_map('strtolower', array_filter($categoryPath)))
: array_values(array_map('strtolower', array_filter(explode('/', (string) $categoryPath))));
if (empty($parts)) {
return null;
}
$last = end($parts);
$category = static::where('slug', $last)
->whereHas('contentType', function ($q) use ($contentTypeSlug) {
$q->where('slug', strtolower($contentTypeSlug));
})
->first();
if (! $category) {
return null;
}
// Verify parent chain matches the preceding parts in the path
$idx = count($parts) - 2;
$current = $category;
while ($idx >= 0) {
$parent = $current->parent;
if (! $parent || $parent->slug !== $parts[$idx]) {
return null;
}
$current = $parent;
$idx--;
}
return $category;
}
private static function decodeHtmlEntities(?string $value): ?string
{
if ($value === null) {
return null;
}
$decoded = $value;
for ($index = 0; $index < 5; $index++) {
$next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($next === $decoded) {
break;
}
$decoded = $next;
}
return $decoded;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CategorySeo extends Model
{
protected $table = 'category_seo';
protected $primaryKey = 'category_id';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'category_id','meta_title','meta_description',
'meta_keywords','canonical_url'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CategoryTranslation extends Model
{
use SoftDeletes;
public $timestamps = false;
protected $fillable = [
'category_id','locale','name','description'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,736 @@
<?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 App\Models\Group;
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',
'group_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 group(): BelongsTo
{
return $this->belongsTo(Group::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;
if ($userId === null) {
return false;
}
if ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
return $group?->hasActiveMember((int) $userId) ?? false;
}
return (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 ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->first();
return (string) ($group?->name ?: 'Skinbase Group');
}
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 ((int) ($this->group_id ?? 0) > 0) {
return null;
}
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 ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
return $group?->activeRoleFor((int) $userId);
}
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
{
if ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
return $group?->canManageCollections($user) ?? false;
}
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
{
if ((int) ($this->group_id ?? 0) > 0) {
$group = $this->relationLoaded('group') ? $this->group : $this->group()->with('members')->first();
return $group?->canManageMembers($user) ?? false;
}
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, bool $hideMature = false): ?Artwork
{
$cover = $this->relationLoaded('coverArtwork') ? $this->coverArtwork : $this->coverArtwork()->first();
if ($cover && $this->artworkMatchesCoverVisibility($cover, $publicOnly, $hideMature)) {
return $cover;
}
$relation = $publicOnly ? 'publicArtworks' : 'artworks';
$artworks = $this->relationLoaded($relation)
? $this->getRelation($relation)
: $this->{$relation}()
->when($hideMature, function ($query): void {
$query->whereRaw('COALESCE(artworks.is_mature, 0) = 0')
->whereRaw("COALESCE(artworks.maturity_status, 'clear') != ?", ['suspected']);
})
->limit(1)
->get();
return $artworks
->first(fn (Artwork $artwork): bool => $this->artworkMatchesCoverVisibility($artwork, $publicOnly, $hideMature));
}
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());
}
private function artworkMatchesCoverVisibility(Artwork $artwork, bool $publicOnly, bool $hideMature): bool
{
if ($publicOnly && ! $this->artworkIsPubliclyVisible($artwork)) {
return false;
}
if (! $hideMature) {
return true;
}
return ! (bool) $artwork->is_mature
&& (string) ($artwork->maturity_status ?? 'clear') !== 'suspected';
}
}

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');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $comment_id
* @property int $user_id
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
*/
class CommentReaction extends Model
{
public $timestamps = false;
protected $table = 'comment_reactions';
protected $fillable = ['comment_id', 'user_id', 'reaction'];
protected $casts = [
'comment_id' => 'integer',
'user_id' => 'integer',
'created_at' => 'datetime',
];
public function comment(): BelongsTo
{
return $this->belongsTo(ArtworkComment::class, 'comment_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentModerationActionLog extends Model
{
protected $table = 'content_moderation_action_logs';
public $timestamps = false;
protected $fillable = [
'finding_id',
'target_type',
'target_id',
'action_type',
'actor_type',
'actor_id',
'old_status',
'new_status',
'old_visibility',
'new_visibility',
'notes',
'meta_json',
'created_at',
];
protected $casts = [
'finding_id' => 'integer',
'target_id' => 'integer',
'actor_id' => 'integer',
'meta_json' => 'array',
'created_at' => 'datetime',
];
public function finding(): BelongsTo
{
return $this->belongsTo(ContentModerationFinding::class, 'finding_id');
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentModerationAiSuggestion extends Model
{
protected $table = 'content_moderation_ai_suggestions';
public $timestamps = false;
protected $fillable = [
'finding_id',
'provider',
'suggested_label',
'suggested_action',
'confidence',
'explanation',
'campaign_tags_json',
'raw_response_json',
'created_at',
];
protected $casts = [
'finding_id' => 'integer',
'confidence' => 'integer',
'campaign_tags_json' => 'array',
'raw_response_json' => 'array',
'created_at' => 'datetime',
];
public function finding(): BelongsTo
{
return $this->belongsTo(ContentModerationFinding::class, 'finding_id');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ContentModerationCluster extends Model
{
protected $table = 'content_moderation_clusters';
protected $fillable = [
'campaign_key',
'cluster_reason',
'review_bucket',
'escalation_status',
'cluster_score',
'findings_count',
'unique_users_count',
'unique_domains_count',
'latest_finding_at',
'summary_json',
];
protected $casts = [
'cluster_score' => 'integer',
'findings_count' => 'integer',
'unique_users_count' => 'integer',
'unique_domains_count' => 'integer',
'latest_finding_at' => 'datetime',
'summary_json' => 'array',
];
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use App\Enums\ModerationDomainStatus;
use Illuminate\Database\Eloquent\Model;
class ContentModerationDomain extends Model
{
protected $table = 'content_moderation_domains';
protected $fillable = [
'domain',
'status',
'times_seen',
'times_flagged',
'times_confirmed_spam',
'linked_users_count',
'linked_findings_count',
'linked_clusters_count',
'first_seen_at',
'last_seen_at',
'top_keywords_json',
'top_content_types_json',
'false_positive_count',
'notes',
];
protected $casts = [
'status' => ModerationDomainStatus::class,
'times_seen' => 'integer',
'times_flagged' => 'integer',
'times_confirmed_spam' => 'integer',
'linked_users_count' => 'integer',
'linked_findings_count' => 'integer',
'linked_clusters_count' => 'integer',
'first_seen_at' => 'datetime',
'last_seen_at' => 'datetime',
'top_keywords_json' => 'array',
'top_content_types_json' => 'array',
'false_positive_count' => 'integer',
];
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentModerationFeedback extends Model
{
protected $table = 'content_moderation_feedback';
public $timestamps = false;
protected $fillable = [
'finding_id',
'feedback_type',
'actor_id',
'notes',
'meta_json',
'created_at',
];
protected $casts = [
'finding_id' => 'integer',
'actor_id' => 'integer',
'meta_json' => 'array',
'created_at' => 'datetime',
];
public function finding(): BelongsTo
{
return $this->belongsTo(ContentModerationFinding::class, 'finding_id');
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Models;
use App\Enums\ModerationEscalationStatus;
use App\Enums\ModerationContentType;
use App\Enums\ModerationSeverity;
use App\Enums\ModerationStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property ModerationContentType $content_type
* @property int $content_id
* @property int|null $artwork_id
* @property int|null $user_id
* @property ModerationStatus $status
* @property ModerationSeverity $severity
* @property int $score
* @property string $content_hash
* @property string $scanner_version
* @property array|null $reasons_json
* @property array|null $matched_links_json
* @property array|null $matched_domains_json
* @property array|null $matched_keywords_json
* @property string|null $content_snapshot
* @property int|null $reviewed_by
* @property \Illuminate\Support\Carbon|null $reviewed_at
* @property string|null $action_taken
* @property string|null $admin_notes
* @property string|null $content_hash_normalized
* @property string|null $group_key
* @property array|null $rule_hits_json
* @property array|null $domain_ids_json
* @property int|null $user_risk_score
* @property bool $is_auto_hidden
* @property string|null $auto_action_taken
* @property \Illuminate\Support\Carbon|null $auto_hidden_at
* @property int|null $resolved_by
* @property \Illuminate\Support\Carbon|null $resolved_at
* @property int|null $restored_by
* @property \Illuminate\Support\Carbon|null $restored_at
* @property string|null $content_target_type
* @property int|null $content_target_id
* @property string|null $campaign_key
* @property int|null $cluster_score
* @property string|null $cluster_reason
* @property int|null $priority_score
* @property string|null $ai_provider
* @property string|null $ai_label
* @property int|null $ai_confidence
* @property string|null $ai_explanation
* @property bool $is_false_positive
* @property int $false_positive_count
* @property string|null $policy_name
* @property string|null $review_bucket
* @property ModerationEscalationStatus $escalation_status
* @property array|null $score_breakdown_json
*/
class ContentModerationFinding extends Model
{
protected $table = 'content_moderation_findings';
protected $fillable = [
'content_type',
'content_id',
'artwork_id',
'user_id',
'status',
'severity',
'score',
'content_hash',
'scanner_version',
'reasons_json',
'matched_links_json',
'matched_domains_json',
'matched_keywords_json',
'content_snapshot',
'reviewed_by',
'reviewed_at',
'action_taken',
'admin_notes',
'content_hash_normalized',
'group_key',
'rule_hits_json',
'domain_ids_json',
'user_risk_score',
'is_auto_hidden',
'auto_action_taken',
'auto_hidden_at',
'resolved_by',
'resolved_at',
'restored_by',
'restored_at',
'content_target_type',
'content_target_id',
'campaign_key',
'cluster_score',
'cluster_reason',
'priority_score',
'ai_provider',
'ai_label',
'ai_confidence',
'ai_explanation',
'is_false_positive',
'false_positive_count',
'policy_name',
'review_bucket',
'escalation_status',
'score_breakdown_json',
];
protected $casts = [
'content_type' => ModerationContentType::class,
'status' => ModerationStatus::class,
'severity' => ModerationSeverity::class,
'score' => 'integer',
'reasons_json' => 'array',
'matched_links_json' => 'array',
'matched_domains_json' => 'array',
'matched_keywords_json' => 'array',
'rule_hits_json' => 'array',
'domain_ids_json' => 'array',
'user_risk_score' => 'integer',
'is_auto_hidden' => 'boolean',
'reviewed_at' => 'datetime',
'auto_hidden_at' => 'datetime',
'resolved_at' => 'datetime',
'restored_at' => 'datetime',
'content_target_id' => 'integer',
'cluster_score' => 'integer',
'priority_score' => 'integer',
'ai_confidence' => 'integer',
'is_false_positive' => 'boolean',
'false_positive_count' => 'integer',
'escalation_status' => ModerationEscalationStatus::class,
'score_breakdown_json' => 'array',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by');
}
public function resolver(): BelongsTo
{
return $this->belongsTo(User::class, 'resolved_by');
}
public function restorer(): BelongsTo
{
return $this->belongsTo(User::class, 'restored_by');
}
public function actionLogs(): HasMany
{
return $this->hasMany(ContentModerationActionLog::class, 'finding_id')->orderByDesc('created_at');
}
public function aiSuggestions(): HasMany
{
return $this->hasMany(ContentModerationAiSuggestion::class, 'finding_id')->orderByDesc('created_at');
}
public function feedback(): HasMany
{
return $this->hasMany(ContentModerationFeedback::class, 'finding_id')->orderByDesc('created_at');
}
public function isPending(): bool
{
return $this->status === ModerationStatus::Pending;
}
public function isReviewed(): bool
{
return in_array($this->status, [
ModerationStatus::ReviewedSafe,
ModerationStatus::ConfirmedSpam,
ModerationStatus::Resolved,
], true);
}
public function hasMatchedDomains(): bool
{
return ! empty($this->matched_domains_json);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Enums\ModerationRuleType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentModerationRule extends Model
{
protected $table = 'content_moderation_rules';
protected $fillable = [
'type',
'value',
'enabled',
'weight',
'notes',
'created_by',
];
protected $casts = [
'type' => ModerationRuleType::class,
'enabled' => 'boolean',
'weight' => 'integer',
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use App\Models\Artwork;
class ContentType extends Model
{
protected $fillable = ['name','slug','description','order','hide_from_menu','mascot_path','cover_art_path'];
protected $casts = [
'order' => 'integer',
'hide_from_menu' => 'boolean',
];
public function scopeOrdered(EloquentBuilder $query): EloquentBuilder
{
return $query->orderBy('order')->orderBy('name')->orderBy('id');
}
public function scopeVisibleInToolbar(EloquentBuilder $query): EloquentBuilder
{
return $query->where('hide_from_menu', false);
}
public function categories(): HasMany
{
return $this->hasMany(Category::class);
}
public function rootCategories(): HasMany
{
return $this->categories()->whereNull('parent_id');
}
public function slugHistories(): HasMany
{
return $this->hasMany(ContentTypeSlugHistory::class);
}
/**
* Return an Eloquent builder for Artworks that belong to this content type.
* This traverses the pivot `artwork_category` via the `categories` relation.
* Note: not a direct Eloquent relation (uses whereHas) so it can be queried/eager-loaded manually.
*/
public function artworks(): EloquentBuilder
{
return Artwork::whereHas('categories', function ($q) {
$q->where('content_type_id', $this->id);
});
}
public function getMascotUrlAttribute(): ?string
{
return $this->resolveAssetUrl($this->mascot_path);
}
public function getCoverArtUrlAttribute(): ?string
{
return $this->resolveAssetUrl($this->cover_art_path);
}
public function getRouteKeyName(): string
{
return 'slug';
}
private function resolveAssetUrl(?string $path): ?string
{
$path = trim((string) $path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentTypeSlugHistory extends Model
{
protected $fillable = [
'content_type_id',
'old_slug',
];
public function contentType(): BelongsTo
{
return $this->belongsTo(ContentType::class);
}
}

View File

@@ -0,0 +1,127 @@
<?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;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* @property int $id
* @property string $type direct|group
* @property string|null $title
* @property int $created_by
* @property \Carbon\Carbon|null $last_message_at
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class Conversation extends Model
{
use HasFactory;
protected $fillable = [
'uuid',
'type',
'title',
'created_by',
'last_message_id',
'last_message_at',
'is_active',
];
protected $casts = [
'last_message_at' => 'datetime',
'is_active' => 'boolean',
];
// ── Relationships ────────────────────────────────────────────────────────
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function participants(): BelongsToMany
{
return $this->belongsToMany(User::class, 'conversation_participants')
->withPivot(['role', 'last_read_at', 'is_muted', 'is_archived', 'is_pinned', 'pinned_at', 'joined_at', 'left_at'])
->wherePivotNull('left_at');
}
public function allParticipants(): HasMany
{
return $this->hasMany(ConversationParticipant::class);
}
public function messages(): HasMany
{
return $this->hasMany(Message::class)->orderBy('created_at');
}
public function latestMessage(): HasOne
{
return $this->hasOne(Message::class)->whereNull('deleted_at')->latestOfMany();
}
// ── Helpers ─────────────────────────────────────────────────────────────
public function isDirect(): bool
{
return $this->type === 'direct';
}
public function isGroup(): bool
{
return $this->type === 'group';
}
/**
* Find an existing direct conversation between exactly two users, or null.
*/
public static function findDirect(int $userA, int $userB): ?self
{
return self::query()
->where('type', 'direct')
->where('is_active', true)
->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userA)->whereNull('left_at'))
->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userB)->whereNull('left_at'))
->whereRaw(
'(select count(*) from conversation_participants'
.' where conversation_participants.conversation_id = conversations.id'
.' and left_at is null) = 2'
)
->first();
}
/**
* Compute unread count for a given participant.
*/
public function unreadCountFor(int $userId): int
{
$participant = $this->allParticipants()
->where('user_id', $userId)
->first();
if (! $participant) {
return 0;
}
$query = $this->messages()
->whereNull('deleted_at')
->where('sender_id', '!=', $userId);
if ($participant->last_read_message_id) {
$query->where('id', '>', $participant->last_read_message_id);
return $query->count();
}
if ($participant->last_read_at) {
$query->where('created_at', '>', $participant->last_read_at);
}
return $query->count();
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $conversation_id
* @property int $user_id
* @property string $role member|admin
* @property \Carbon\Carbon|null $last_read_at
* @property bool $is_muted
* @property bool $is_archived
* @property bool $is_pinned
* @property \Carbon\Carbon|null $pinned_at
* @property \Carbon\Carbon $joined_at
* @property \Carbon\Carbon|null $left_at
*/
class ConversationParticipant extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'conversation_id',
'user_id',
'role',
'last_read_at',
'last_read_message_id',
'is_muted',
'is_archived',
'is_pinned',
'is_hidden',
'pinned_at',
'joined_at',
'left_at',
];
protected $casts = [
'last_read_at' => 'datetime',
'last_read_message_id' => 'integer',
'is_muted' => 'boolean',
'is_archived' => 'boolean',
'is_pinned' => 'boolean',
'is_hidden' => 'boolean',
'pinned_at' => 'datetime',
'joined_at' => 'datetime',
'left_at' => 'datetime',
];
// ── Relationships ────────────────────────────────────────────────────────
public function conversation(): BelongsTo
{
return $this->belongsTo(Conversation::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,88 @@
<?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\HasMany;
final class Country extends Model
{
use HasFactory;
protected $fillable = [
'iso',
'iso2',
'iso3',
'numeric_code',
'name',
'native',
'phone',
'continent',
'capital',
'currency',
'languages',
'name_common',
'name_official',
'region',
'subregion',
'flag_svg_url',
'flag_png_url',
'flag_emoji',
'active',
'sort_order',
'is_featured',
];
protected function casts(): array
{
return [
'active' => 'boolean',
'is_featured' => 'boolean',
'sort_order' => 'integer',
];
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query
->orderByDesc('is_featured')
->orderBy('sort_order')
->orderBy('name_common');
}
public function getFlagCssClassAttribute(): ?string
{
$iso2 = strtoupper((string) $this->iso2);
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
return null;
}
return 'fi fi-'.strtolower($iso2);
}
public function getLocalFlagPathAttribute(): ?string
{
$iso2 = strtoupper((string) $this->iso2);
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
return null;
}
return '/gfx/flags/shiny/24/'.rawurlencode($iso2).'.png';
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DashboardPreference extends Model
{
public const MAX_PINNED_SPACES = 8;
/**
* @var list<string>
*/
private const ALLOWED_PINNED_SPACES = [
'/dashboard/profile',
'/dashboard/notifications',
'/dashboard/comments/received',
'/dashboard/followers',
'/dashboard/following',
'/dashboard/favorites',
'/dashboard/artworks',
'/dashboard/gallery',
'/dashboard/awards',
'/creator/stories',
'/studio',
];
protected $table = 'dashboard_preferences';
protected $primaryKey = 'user_id';
public $incrementing = false;
protected $keyType = 'int';
protected $fillable = [
'user_id',
'pinned_spaces',
'studio_preferences',
];
protected function casts(): array
{
return [
'pinned_spaces' => 'array',
'studio_preferences' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* @param array<int, mixed> $hrefs
* @return list<string>
*/
public static function sanitizePinnedSpaces(array $hrefs): array
{
$allowed = array_fill_keys(self::ALLOWED_PINNED_SPACES, true);
$sanitized = [];
foreach ($hrefs as $href) {
if (! is_string($href) || ! isset($allowed[$href])) {
continue;
}
if (in_array($href, $sanitized, true)) {
continue;
}
$sanitized[] = $href;
if (count($sanitized) >= self::MAX_PINNED_SPACES) {
break;
}
}
return $sanitized;
}
/**
* @return list<string>
*/
public static function pinnedSpacesForUser(User $user): array
{
$preference = static::query()->find($user->id);
$spaces = $preference?->pinned_spaces;
return is_array($spaces) ? static::sanitizePinnedSpaces($spaces) : [];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class EmailSendEvent extends Model
{
protected $table = 'email_send_events';
public $timestamps = false;
protected $fillable = [
'type',
'email',
'ip',
'user_id',
'status',
'reason',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ForumAttachment extends Model
{
protected $table = 'forum_attachments';
protected $fillable = [
'id','post_id','file_path','file_size','mime_type','width','height'
];
public $incrementing = true;
public function post()
{
return $this->belongsTo(ForumPost::class, 'post_id');
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class ForumCategory extends Model
{
protected $table = 'forum_categories';
protected $fillable = [
'id', 'name', 'slug', 'parent_id', 'position'
];
public $incrementing = true;
public function parent(): BelongsTo
{
return $this->belongsTo(ForumCategory::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(ForumCategory::class, 'parent_id');
}
public function threads(): HasMany
{
return $this->hasMany(ForumThread::class, 'category_id');
}
public function postsThroughThreads(): HasManyThrough
{
return $this->hasManyThrough(
ForumPost::class,
ForumThread::class,
'category_id',
'thread_id',
'id',
'id'
);
}
public function lastThread(): HasOne
{
return $this->hasOne(ForumThread::class, 'category_id')->latestOfMany('last_post_at');
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('position')->orderBy('id');
}
public function scopeRoots(Builder $query): Builder
{
return $query->whereNull('parent_id');
}
public function scopeWithForumStats(Builder $query): Builder
{
return $query
->withCount(['threads as thread_count'])
->withCount(['postsThroughThreads as post_count'])
->with(['lastThread' => function ($relationQuery) {
$relationQuery->select([
'forum_threads.id',
'forum_threads.category_id',
'forum_threads.last_post_at',
'forum_threads.updated_at',
]);
}]);
}
public function getPreviewImageAttribute(): string
{
$slug = (string) ($this->slug ?? '');
$map = (array) config('forum.preview_images.map', []);
$default = (string) config('forum.preview_images.default', '/images/forum/default.jpg');
if ($slug !== '' && !empty($map[$slug])) {
return (string) $map[$slug];
}
if ($slug !== '') {
return '/images/forum/' . $slug . '.webp';
}
return $default;
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ForumPost extends Model
{
use SoftDeletes;
protected $table = 'forum_posts';
protected $fillable = [
'id',
'thread_id',
'topic_id',
'source_ip_hash',
'user_id',
'content',
'content_hash',
'is_edited',
'edited_at',
'spam_score',
'quality_score',
'ai_spam_score',
'ai_toxicity_score',
'behavior_score',
'link_score',
'learning_score',
'risk_score',
'trust_modifier',
'flagged',
'flagged_reason',
'moderation_checked',
'moderation_status',
'moderation_labels',
'moderation_meta',
'last_ai_scan_at',
];
public $incrementing = true;
protected $casts = [
'is_edited' => 'boolean',
'edited_at' => 'datetime',
'spam_score' => 'integer',
'quality_score' => 'integer',
'ai_spam_score' => 'integer',
'ai_toxicity_score' => 'integer',
'behavior_score' => 'integer',
'link_score' => 'integer',
'learning_score' => 'integer',
'risk_score' => 'integer',
'trust_modifier' => 'integer',
'flagged' => 'boolean',
'moderation_checked' => 'boolean',
'moderation_labels' => 'array',
'moderation_meta' => 'array',
'last_ai_scan_at' => 'datetime',
];
public function thread(): BelongsTo
{
return $this->belongsTo(ForumThread::class, 'thread_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function attachments(): HasMany
{
return $this->hasMany(ForumAttachment::class, 'post_id');
}
public function scopeInThread(Builder $query, int $threadId): Builder
{
return $query->where('thread_id', $threadId);
}
public function scopeVisible(Builder $query): Builder
{
return $query;
}
public function scopePinned(Builder $query): Builder
{
return $query->whereHas('thread', fn (Builder $threadQuery) => $threadQuery->where('is_pinned', true));
}
public function scopeRecent(Builder $query): Builder
{
return $query->orderByDesc('created_at')->orderByDesc('id');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ForumPostReport extends Model
{
protected $table = 'forum_post_reports';
protected $fillable = [
'post_id',
'thread_id',
'reporter_user_id',
'reason',
'status',
'source_url',
'reported_at',
];
protected $casts = [
'reported_at' => 'datetime',
];
public function post(): BelongsTo
{
return $this->belongsTo(ForumPost::class, 'post_id');
}
public function thread(): BelongsTo
{
return $this->belongsTo(ForumThread::class, 'thread_id');
}
public function reporter(): BelongsTo
{
return $this->belongsTo(User::class, 'reporter_user_id');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ForumThread extends Model
{
use SoftDeletes;
protected $table = 'forum_threads';
protected $fillable = [
'id','category_id','user_id','title','slug','content','views','is_locked','is_pinned','visibility','last_post_at'
];
public $incrementing = true;
protected $casts = [
'is_locked' => 'boolean',
'is_pinned' => 'boolean',
'last_post_at' => 'datetime',
];
public function category(): BelongsTo
{
return $this->belongsTo(ForumCategory::class, 'category_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function posts(): HasMany
{
return $this->hasMany(ForumPost::class, 'thread_id');
}
public function scopeVisible(Builder $query): Builder
{
return $query->where('visibility', 'public');
}
public function scopePinned(Builder $query): Builder
{
return $query->where('is_pinned', true);
}
public function scopeRecent(Builder $query): Builder
{
return $query->orderByDesc('last_post_at')->orderByDesc('id');
}
public function scopeInCategory(Builder $query, int $categoryId): Builder
{
return $query->where('category_id', $categoryId);
}
}

View File

@@ -0,0 +1,868 @@
<?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\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
class Group extends Model
{
use HasFactory;
use SoftDeletes;
use Searchable;
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_PRIVATE = 'private';
public const VISIBILITY_UNLISTED = 'unlisted';
public const LIFECYCLE_ACTIVE = 'active';
public const LIFECYCLE_ARCHIVED = 'archived';
public const LIFECYCLE_SUSPENDED = 'suspended';
public const MEMBERSHIP_INVITE_ONLY = 'invite_only';
public const MEMBERSHIP_REQUEST_TO_JOIN = 'request_to_join';
public const MEMBERSHIP_OPEN = 'open';
public const ROLE_OWNER = 'owner';
public const ROLE_ADMIN = 'admin';
public const ROLE_EDITOR = 'editor';
public const ROLE_MEMBER = 'member';
public const ROLE_CONTRIBUTOR = 'contributor';
public const PERMISSION_REVIEW_JOIN_REQUESTS = 'review_join_requests';
public const PERMISSION_REVIEW_SUBMISSIONS = 'review_submissions';
public const PERMISSION_MANAGE_RECRUITMENT = 'manage_recruitment';
public const PERMISSION_MANAGE_POSTS = 'manage_posts';
public const PERMISSION_PUBLISH_POSTS = 'publish_posts';
public const PERMISSION_PIN_POSTS = 'pin_posts';
public const PERMISSION_MANAGE_MEMBER_PERMISSIONS = 'manage_member_permissions';
public const PERMISSION_MANAGE_EVENTS = 'manage_events';
public const PERMISSION_MANAGE_CHALLENGES = 'manage_challenges';
public const PERMISSION_MANAGE_PROJECTS = 'manage_projects';
public const PERMISSION_MANAGE_RELEASES = 'manage_releases';
public const PERMISSION_PUBLISH_RELEASES = 'publish_releases';
public const PERMISSION_MANAGE_MILESTONES = 'manage_milestones';
public const PERMISSION_VIEW_REPUTATION_DASHBOARD = 'view_reputation_dashboard';
public const PERMISSION_MANAGE_BADGES = 'manage_badges';
public const PERMISSION_VIEW_INTERNAL_TRUST_METRICS = 'view_internal_trust_metrics';
public const PERMISSION_FEATURE_RELEASES = 'feature_releases';
public const PERMISSION_ASSIGN_RELEASE_LEAD = 'assign_release_lead';
public const PERMISSION_MANAGE_ASSETS = 'manage_assets';
public const PERMISSION_FEATURE_CHALLENGE_ENTRIES = 'feature_challenge_entries';
public const PERMISSION_PUBLISH_EVENT_UPDATES = 'publish_event_updates';
public const PERMISSION_ATTACH_ASSETS_TO_PROJECTS = 'attach_assets_to_projects';
public const PERMISSION_VIEW_INTERNAL_ASSETS = 'view_internal_assets';
public const PERMISSION_MANAGE_ACTIVITY_PINS = 'manage_activity_pins';
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_REVOKED = 'revoked';
protected $fillable = [
'owner_user_id',
'featured_artwork_id',
'is_verified',
'founded_at',
'name',
'slug',
'headline',
'bio',
'type',
'visibility',
'status',
'membership_policy',
'website_url',
'links_json',
'avatar_path',
'banner_path',
'artworks_count',
'collections_count',
'followers_count',
'last_activity_at',
];
protected $casts = [
'links_json' => 'array',
'is_verified' => 'boolean',
'artworks_count' => 'integer',
'collections_count' => 'integer',
'followers_count' => 'integer',
'founded_at' => 'datetime',
'last_activity_at' => 'datetime',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
public function getRouteKeyName(): string
{
return 'slug';
}
public function members(): HasMany
{
return $this->hasMany(GroupMember::class);
}
public function invitations(): HasMany
{
return $this->hasMany(GroupInvitation::class);
}
public function follows(): HasMany
{
return $this->hasMany(GroupFollow::class);
}
public function artworks(): HasMany
{
return $this->hasMany(Artwork::class);
}
public function collections(): HasMany
{
return $this->hasMany(Collection::class);
}
public function joinRequests(): HasMany
{
return $this->hasMany(GroupJoinRequest::class);
}
public function posts(): HasMany
{
return $this->hasMany(GroupPost::class);
}
public function recruitmentProfile(): HasOne
{
return $this->hasOne(GroupRecruitmentProfile::class);
}
public function projects(): HasMany
{
return $this->hasMany(GroupProject::class);
}
public function releases(): HasMany
{
return $this->hasMany(GroupRelease::class);
}
public function challenges(): HasMany
{
return $this->hasMany(GroupChallenge::class);
}
public function events(): HasMany
{
return $this->hasMany(GroupEvent::class);
}
public function assets(): HasMany
{
return $this->hasMany(GroupAsset::class);
}
public function activityItems(): HasMany
{
return $this->hasMany(GroupActivityItem::class);
}
public function contributorStats(): HasMany
{
return $this->hasMany(GroupContributorStat::class);
}
public function badges(): HasMany
{
return $this->hasMany(GroupBadge::class);
}
public function memberBadges(): HasMany
{
return $this->hasMany(GroupMemberBadge::class);
}
public function discoveryMetric(): HasOne
{
return $this->hasOne(GroupDiscoveryMetric::class);
}
public function historyEntries(): HasMany
{
return $this->hasMany(GroupHistory::class);
}
public function scopePublic(Builder $query): Builder
{
return $query
->where('visibility', self::VISIBILITY_PUBLIC)
->where('status', self::LIFECYCLE_ACTIVE);
}
public static function acceptedVisibilityValues(): array
{
return [self::VISIBILITY_PUBLIC, self::VISIBILITY_PRIVATE, self::VISIBILITY_UNLISTED];
}
public static function acceptedMembershipPolicies(): array
{
return [self::MEMBERSHIP_INVITE_ONLY, self::MEMBERSHIP_REQUEST_TO_JOIN, self::MEMBERSHIP_OPEN];
}
public static function normalizeMemberRole(string $role): string
{
$normalized = strtolower(trim($role));
return $normalized === self::ROLE_CONTRIBUTOR ? self::ROLE_MEMBER : $normalized;
}
public static function displayRole(?string $role): ?string
{
if ($role === null) {
return null;
}
return self::normalizeMemberRole($role) === self::ROLE_MEMBER ? self::ROLE_CONTRIBUTOR : self::normalizeMemberRole($role);
}
public function isOwnedBy(User|int|null $user): bool
{
$userId = $user instanceof User ? $user->id : $user;
return $userId !== null && (int) $userId === (int) $this->owner_user_id;
}
public function isPubliclyVisible(): bool
{
return in_array($this->visibility, [self::VISIBILITY_PUBLIC, self::VISIBILITY_UNLISTED], true)
&& $this->status !== self::LIFECYCLE_SUSPENDED;
}
public function isOperational(): bool
{
return $this->status === self::LIFECYCLE_ACTIVE;
}
public function canArchive(User $user): bool
{
return $this->isOwnedBy($user);
}
public function canViewStudio(User $user): bool
{
if ($this->status === self::LIFECYCLE_SUSPENDED) {
return false;
}
return $this->hasActiveMember($user);
}
public function activeRoleFor(User|int|null $user): ?string
{
$userId = $user instanceof User ? $user->id : $user;
if ($userId === null) {
return null;
}
if ($this->isOwnedBy($userId)) {
return self::ROLE_OWNER;
}
$members = $this->relationLoaded('members')
? $this->members
: $this->members()->where('status', self::STATUS_ACTIVE)->get();
return $members
->first(fn (GroupMember $member): bool => (int) $member->user_id === (int) $userId && $member->status === self::STATUS_ACTIVE)
?->role;
}
public function hasActiveMember(User|int|null $user): bool
{
return $this->activeRoleFor($user) !== null;
}
public function activeMembershipFor(User|int|null $user): ?GroupMember
{
$userId = $user instanceof User ? $user->id : $user;
if ($userId === null || $this->isOwnedBy($userId)) {
return null;
}
$members = $this->relationLoaded('members')
? $this->members
: $this->members()->where('status', self::STATUS_ACTIVE)->get();
return $members->first(
fn (GroupMember $member): bool => (int) $member->user_id === (int) $userId && $member->status === self::STATUS_ACTIVE
);
}
public function permissionOverridesFor(User|int|null $user): array
{
if ($this->isOwnedBy($user)) {
return collect(self::allowedPermissionOverrides())
->mapWithKeys(fn (string $permission): array => [$permission => true])
->all();
}
$member = $this->activeMembershipFor($user);
if (! $member) {
return [];
}
return collect($member->permission_overrides_json ?? [])
->mapWithKeys(function ($override): array {
if (is_array($override)) {
$key = trim((string) ($override['key'] ?? ''));
if ($key === '' || ! in_array($key, self::allowedPermissionOverrides(), true)) {
return [];
}
return [$key => (bool) ($override['is_allowed'] ?? false)];
}
$key = trim((string) $override);
if ($key === '' || ! in_array($key, self::allowedPermissionOverrides(), true)) {
return [];
}
return [$key => true];
})
->all();
}
public function hasPermission(User|int|null $user, string $permission): bool
{
return $this->permissionOverridesFor($user)[$permission] ?? false;
}
public function hasDeniedPermission(User|int|null $user, string $permission): bool
{
$overrides = $this->permissionOverridesFor($user);
return array_key_exists($permission, $overrides) && $overrides[$permission] === false;
}
public static function permissionKeys(): array
{
return self::allowedPermissionOverrides();
}
public function canBeViewedBy(?User $user): bool
{
if ($this->isPubliclyVisible()) {
return true;
}
return $user !== null && $this->hasActiveMember($user);
}
public function canManage(User $user): bool
{
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true);
}
public function canManageMembers(User $user): bool
{
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true);
}
public function canPublishArtworks(User $user): bool
{
return $this->isOperational()
&& in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true);
}
public function canCreateArtworkDrafts(User $user): bool
{
return $this->isOperational() && $this->hasActiveMember($user);
}
public function canSubmitArtworkForReview(User $user): bool
{
return $this->isOperational() && $this->hasActiveMember($user);
}
public function canReviewSubmissions(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_REVIEW_SUBMISSIONS)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_REVIEW_SUBMISSIONS);
}
public function canRequestJoin(?User $user): bool
{
if (! $this->isOperational() || $user === null || $this->hasActiveMember($user)) {
return false;
}
return in_array($this->membership_policy, [self::MEMBERSHIP_REQUEST_TO_JOIN, self::MEMBERSHIP_OPEN], true);
}
public function canReviewJoinRequests(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_REVIEW_JOIN_REQUESTS)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_REVIEW_JOIN_REQUESTS);
}
public function canManageRecruitment(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_RECRUITMENT)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_RECRUITMENT);
}
public function canManagePosts(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_POSTS)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_POSTS);
}
public function canPublishPosts(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_POSTS)) {
return false;
}
$role = $this->activeRoleFor($user);
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_PUBLISH_POSTS)
|| $this->canManagePosts($user);
}
public function canPinPosts(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_PIN_POSTS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_PIN_POSTS);
}
public function canManageMemberPermissions(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_MEMBER_PERMISSIONS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_MEMBER_PERMISSIONS);
}
public function canManageEvents(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_EVENTS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_EVENTS);
}
public function canManageChallenges(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_CHALLENGES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_CHALLENGES);
}
public function canManageProjects(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_PROJECTS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_PROJECTS);
}
public function canManageReleases(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_RELEASES)) {
return false;
}
return $this->canManageProjects($user)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_RELEASES);
}
public function canPublishReleases(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_RELEASES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_PUBLISH_RELEASES)
|| $this->canManageReleases($user);
}
public function canManageMilestones(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_MILESTONES)) {
return false;
}
return $this->canManageProjects($user)
|| $this->canManageReleases($user)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_MILESTONES);
}
public function canViewReputationDashboard(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_REPUTATION_DASHBOARD)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_VIEW_REPUTATION_DASHBOARD);
}
public function canManageBadges(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_BADGES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_BADGES);
}
public function canViewInternalTrustMetrics(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS);
}
public function canFeatureReleases(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_FEATURE_RELEASES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_FEATURE_RELEASES);
}
public function canAssignReleaseLead(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_ASSIGN_RELEASE_LEAD)) {
return false;
}
return $this->canManageReleases($user)
|| $this->hasPermission($user, self::PERMISSION_ASSIGN_RELEASE_LEAD);
}
public function canManageAssets(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_ASSETS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_ASSETS);
}
public function canFeatureChallengeEntries(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_FEATURE_CHALLENGE_ENTRIES)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_FEATURE_CHALLENGE_ENTRIES);
}
public function canPublishEventUpdates(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_PUBLISH_EVENT_UPDATES)) {
return false;
}
return $this->canManageEvents($user)
|| $this->hasPermission($user, self::PERMISSION_PUBLISH_EVENT_UPDATES);
}
public function canAttachAssetsToProjects(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS)) {
return false;
}
return $this->canManageProjects($user)
|| $this->hasPermission($user, self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS);
}
public function canViewInternalAssets(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_VIEW_INTERNAL_ASSETS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_EDITOR], true)
|| $this->hasPermission($user, self::PERMISSION_VIEW_INTERNAL_ASSETS);
}
public function canPinActivity(User $user): bool
{
if (! $this->isOperational()) {
return false;
}
if ($this->hasDeniedPermission($user, self::PERMISSION_MANAGE_ACTIVITY_PINS)) {
return false;
}
return in_array($this->activeRoleFor($user), [self::ROLE_OWNER, self::ROLE_ADMIN], true)
|| $this->hasPermission($user, self::PERMISSION_MANAGE_ACTIVITY_PINS);
}
public static function allowedPermissionOverrides(): array
{
return [
self::PERMISSION_REVIEW_JOIN_REQUESTS,
self::PERMISSION_REVIEW_SUBMISSIONS,
self::PERMISSION_MANAGE_RECRUITMENT,
self::PERMISSION_MANAGE_POSTS,
self::PERMISSION_PUBLISH_POSTS,
self::PERMISSION_PIN_POSTS,
self::PERMISSION_MANAGE_MEMBER_PERMISSIONS,
self::PERMISSION_MANAGE_EVENTS,
self::PERMISSION_MANAGE_CHALLENGES,
self::PERMISSION_MANAGE_PROJECTS,
self::PERMISSION_MANAGE_RELEASES,
self::PERMISSION_PUBLISH_RELEASES,
self::PERMISSION_MANAGE_MILESTONES,
self::PERMISSION_VIEW_REPUTATION_DASHBOARD,
self::PERMISSION_MANAGE_BADGES,
self::PERMISSION_VIEW_INTERNAL_TRUST_METRICS,
self::PERMISSION_FEATURE_RELEASES,
self::PERMISSION_ASSIGN_RELEASE_LEAD,
self::PERMISSION_MANAGE_ASSETS,
self::PERMISSION_FEATURE_CHALLENGE_ENTRIES,
self::PERMISSION_PUBLISH_EVENT_UPDATES,
self::PERMISSION_ATTACH_ASSETS_TO_PROJECTS,
self::PERMISSION_VIEW_INTERNAL_ASSETS,
self::PERMISSION_MANAGE_ACTIVITY_PINS,
];
}
public function canManageCollections(User $user): bool
{
return $this->isOperational() && $this->canPublishArtworks($user);
}
public function avatarUrl(): ?string
{
return $this->assetUrl($this->avatar_path);
}
public function bannerUrl(): ?string
{
return $this->assetUrl($this->banner_path);
}
public function publicUrl(): string
{
return route('groups.show', ['group' => $this->slug]);
}
public function shouldBeSearchable(): bool
{
return $this->visibility === self::VISIBILITY_PUBLIC && $this->status === self::LIFECYCLE_ACTIVE;
}
public function toSearchableArray(): array
{
$recruitment = $this->relationLoaded('recruitmentProfile')
? $this->recruitmentProfile
: $this->recruitmentProfile()->first();
$memberNames = $this->members()
->with('user:id,name,username')
->where('status', self::STATUS_ACTIVE)
->limit(12)
->get()
->map(fn (GroupMember $member): string => (string) ($member->user?->name ?: $member->user?->username ?: ''))
->filter()
->values()
->all();
return [
'id' => (int) $this->id,
'name' => (string) $this->name,
'slug' => (string) $this->slug,
'headline' => (string) ($this->headline ?? ''),
'bio' => (string) ($this->bio ?? ''),
'type' => (string) ($this->type ?? ''),
'visibility' => (string) $this->visibility,
'status' => (string) ($this->status ?? self::LIFECYCLE_ACTIVE),
'artworks_count' => (int) ($this->artworks_count ?? 0),
'followers_count' => (int) ($this->followers_count ?? 0),
'is_recruiting' => (bool) ($recruitment?->is_recruiting ?? false),
'recruitment_headline' => (string) ($recruitment?->headline ?? ''),
'recruitment_roles' => array_values(array_filter($recruitment?->roles_json ?? [])),
'recruitment_skills' => array_values(array_filter($recruitment?->skills_json ?? [])),
'release_titles' => $this->releases()->where('visibility', GroupRelease::VISIBILITY_PUBLIC)->latest('published_at')->limit(6)->pluck('title')->filter()->values()->all(),
'project_titles' => $this->projects()->where('visibility', GroupProject::VISIBILITY_PUBLIC)->latest('updated_at')->limit(6)->pluck('title')->filter()->values()->all(),
'challenge_titles' => $this->challenges()->where('visibility', GroupChallenge::VISIBILITY_PUBLIC)->latest('updated_at')->limit(6)->pluck('title')->filter()->values()->all(),
'event_titles' => $this->events()->where('visibility', GroupEvent::VISIBILITY_PUBLIC)->latest('start_at')->limit(6)->pluck('title')->filter()->values()->all(),
'badge_keys' => $this->badges()->latest('awarded_at')->limit(6)->pluck('badge_key')->filter()->values()->all(),
'member_names' => $memberNames,
];
}
private function assetUrl(?string $path): ?string
{
$trimmed = trim((string) $path);
if ($trimmed === '') {
return null;
}
if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
return $trimmed;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($trimmed, '/');
}
}

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 GroupActivityItem extends Model
{
use HasFactory;
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_INTERNAL = 'internal';
protected $fillable = [
'group_id',
'type',
'visibility',
'actor_user_id',
'subject_type',
'subject_id',
'headline',
'summary',
'is_pinned',
'occurred_at',
];
protected $casts = [
'is_pinned' => 'boolean',
'occurred_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
}

View File

@@ -0,0 +1,87 @@
<?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 GroupAsset extends Model
{
use HasFactory;
use SoftDeletes;
public const CATEGORY_LOGO = 'logo';
public const CATEGORY_BRAND = 'brand';
public const CATEGORY_PALETTE = 'palette';
public const CATEGORY_WATERMARK = 'watermark';
public const CATEGORY_TEMPLATE = 'template';
public const CATEGORY_REFERENCE = 'reference';
public const CATEGORY_SOURCE_PACK = 'source_pack';
public const CATEGORY_PROMO = 'promo';
public const CATEGORY_MISC = 'misc';
public const VISIBILITY_INTERNAL = 'internal';
public const VISIBILITY_MEMBERS_ONLY = 'members_only';
public const VISIBILITY_PUBLIC_DOWNLOAD = 'public_download';
public const STATUS_ACTIVE = 'active';
public const STATUS_ARCHIVED = 'archived';
protected $fillable = [
'group_id',
'title',
'description',
'category',
'file_path',
'preview_path',
'visibility',
'status',
'linked_project_id',
'uploaded_by_user_id',
'approved_by_user_id',
'is_featured',
'file_meta_json',
];
protected $casts = [
'is_featured' => 'boolean',
'file_meta_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function linkedProject(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'linked_project_id');
}
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by_user_id');
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_user_id');
}
public function canBeViewedBy(?User $viewer): bool
{
if ($this->status !== self::STATUS_ACTIVE) {
return false;
}
return match ($this->visibility) {
self::VISIBILITY_PUBLIC_DOWNLOAD => $this->group->canBeViewedBy($viewer),
self::VISIBILITY_MEMBERS_ONLY => $viewer !== null && $this->group->hasActiveMember($viewer),
default => $viewer !== null && $this->group->canViewInternalAssets($viewer),
};
}
}

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 GroupBadge extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'badge_key',
'awarded_at',
'meta_json',
];
protected $casts = [
'awarded_at' => 'datetime',
'meta_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
}

View File

@@ -0,0 +1,125 @@
<?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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class GroupChallenge extends Model
{
use HasFactory;
use SoftDeletes;
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
public const PARTICIPATION_GROUP_ONLY = 'group_only';
public const PARTICIPATION_INVITE_ONLY = 'invite_only';
public const PARTICIPATION_PUBLIC = 'public';
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ACTIVE = 'active';
public const STATUS_ENDED = 'ended';
public const STATUS_ARCHIVED = 'archived';
protected $fillable = [
'group_id',
'title',
'slug',
'summary',
'description',
'cover_path',
'visibility',
'participation_scope',
'status',
'start_at',
'end_at',
'rules_text',
'submission_instructions',
'judging_mode',
'linked_collection_id',
'linked_project_id',
'created_by_user_id',
'featured_artwork_id',
];
protected $casts = [
'start_at' => 'datetime',
'end_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function linkedCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'linked_collection_id');
}
public function linkedProject(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'linked_project_id');
}
public function featuredArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'featured_artwork_id');
}
public function artworkLinks(): HasMany
{
return $this->hasMany(GroupChallengeArtwork::class);
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'group_challenge_artworks')
->withPivot(['submitted_by_user_id', 'sort_order'])
->withTimestamps()
->orderBy('group_challenge_artworks.sort_order');
}
public function canBeViewedBy(?User $viewer): bool
{
if ($this->visibility !== self::VISIBILITY_PRIVATE) {
return $this->group->canBeViewedBy($viewer);
}
return $viewer !== null && $this->group->canViewStudio($viewer);
}
public function coverUrl(): ?string
{
$path = trim((string) $this->cover_path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

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 GroupChallengeArtwork extends Model
{
use HasFactory;
protected $fillable = [
'group_challenge_id',
'artwork_id',
'submitted_by_user_id',
'sort_order',
];
public function challenge(): BelongsTo
{
return $this->belongsTo(GroupChallenge::class, 'group_challenge_id');
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function submitter(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by_user_id');
}
}

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 GroupContributorStat extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'user_id',
'credited_artworks_count',
'release_count',
'project_count',
'review_actions_count',
'approved_submissions_count',
'reputation_meta_json',
];
protected $casts = [
'credited_artworks_count' => 'integer',
'release_count' => 'integer',
'project_count' => 'integer',
'review_actions_count' => 'integer',
'approved_submissions_count' => 'integer',
'reputation_meta_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

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 GroupDiscoveryMetric extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'freshness_score',
'activity_score',
'release_score',
'trust_score',
'collaboration_score',
'last_calculated_at',
];
protected $casts = [
'freshness_score' => 'float',
'activity_score' => 'float',
'release_score' => 'float',
'trust_score' => 'float',
'collaboration_score' => 'float',
'last_calculated_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
}

View File

@@ -0,0 +1,118 @@
<?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 GroupEvent extends Model
{
use HasFactory;
use SoftDeletes;
public const TYPE_LAUNCH = 'launch';
public const TYPE_CHALLENGE = 'challenge';
public const TYPE_LIVESTREAM = 'livestream';
public const TYPE_MEETUP = 'meetup';
public const TYPE_MILESTONE = 'milestone';
public const TYPE_SHOWCASE = 'showcase';
public const TYPE_INTERNAL_SESSION = 'internal_session';
public const TYPE_RELEASE_WINDOW = 'release_window';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_MEMBERS_ONLY = 'members_only';
public const VISIBILITY_PRIVATE = 'private';
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'group_id',
'title',
'slug',
'summary',
'description',
'event_type',
'visibility',
'start_at',
'end_at',
'timezone',
'cover_path',
'location',
'external_url',
'linked_project_id',
'linked_collection_id',
'linked_challenge_id',
'status',
'is_featured',
'created_by_user_id',
'published_at',
];
protected $casts = [
'start_at' => 'datetime',
'end_at' => 'datetime',
'is_featured' => 'boolean',
'published_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function linkedProject(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'linked_project_id');
}
public function linkedCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'linked_collection_id');
}
public function linkedChallenge(): BelongsTo
{
return $this->belongsTo(GroupChallenge::class, 'linked_challenge_id');
}
public function canBeViewedBy(?User $viewer): bool
{
return match ($this->visibility) {
self::VISIBILITY_PUBLIC => $this->group->canBeViewedBy($viewer),
self::VISIBILITY_MEMBERS_ONLY => $viewer !== null && $this->group->hasActiveMember($viewer),
default => $viewer !== null && $this->group->canViewStudio($viewer),
};
}
public function coverUrl(): ?string
{
$path = trim((string) $this->cover_path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,29 @@
<?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 GroupFollow extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'user_id',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::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 GroupHistory extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'group_id',
'actor_user_id',
'action_type',
'target_type',
'target_id',
'summary',
'before_json',
'after_json',
'created_at',
];
protected $casts = [
'before_json' => 'array',
'after_json' => 'array',
'created_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
}

View File

@@ -0,0 +1,69 @@
<?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 GroupInvitation extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_DECLINED = 'declined';
public const STATUS_REVOKED = 'revoked';
public const STATUS_EXPIRED = 'expired';
protected $fillable = [
'group_id',
'invited_user_id',
'invited_by_user_id',
'source_group_member_id',
'role',
'status',
'token',
'note',
'invited_at',
'expires_at',
'responded_at',
'accepted_at',
'revoked_at',
];
protected $casts = [
'invited_at' => 'datetime',
'expires_at' => 'datetime',
'responded_at' => 'datetime',
'accepted_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'token';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function invitedUser(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_user_id');
}
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by_user_id');
}
public function sourceGroupMember(): BelongsTo
{
return $this->belongsTo(GroupMember::class, 'source_group_member_id');
}
}

View File

@@ -0,0 +1,55 @@
<?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 GroupJoinRequest extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
public const STATUS_WITHDRAWN = 'withdrawn';
public const STATUS_EXPIRED = 'expired';
protected $fillable = [
'group_id',
'user_id',
'message',
'portfolio_url',
'desired_role',
'skills_json',
'status',
'reviewed_by_user_id',
'review_notes',
'reviewed_at',
'expires_at',
];
protected $casts = [
'skills_json' => 'array',
'reviewed_at' => 'datetime',
'expires_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::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,51 @@
<?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 GroupMember extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'user_id',
'invited_by_user_id',
'role',
'status',
'permission_overrides_json',
'note',
'invited_at',
'expires_at',
'accepted_at',
'revoked_at',
];
protected $casts = [
'permission_overrides_json' => 'array',
'invited_at' => 'datetime',
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
'revoked_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::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 GroupMemberBadge extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'user_id',
'badge_key',
'awarded_at',
'meta_json',
];
protected $casts = [
'awarded_at' => 'datetime',
'meta_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,59 @@
<?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 GroupPost extends Model
{
use HasFactory;
use SoftDeletes;
public const TYPE_ANNOUNCEMENT = 'announcement';
public const TYPE_RELEASE = 'release';
public const TYPE_RECRUITMENT = 'recruitment';
public const TYPE_UPDATE = 'update';
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived';
protected $fillable = [
'group_id',
'author_user_id',
'type',
'title',
'slug',
'excerpt',
'content',
'cover_path',
'status',
'is_pinned',
'published_at',
];
protected $casts = [
'is_pinned' => 'boolean',
'published_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_user_id');
}
}

View File

@@ -0,0 +1,162 @@
<?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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class GroupProject extends Model
{
use HasFactory;
use SoftDeletes;
public const STATUS_PLANNED = 'planned';
public const STATUS_ACTIVE = 'active';
public const STATUS_REVIEW = 'review';
public const STATUS_RELEASED = 'released';
public const STATUS_ARCHIVED = 'archived';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
protected $fillable = [
'group_id',
'title',
'slug',
'summary',
'description',
'cover_path',
'status',
'visibility',
'start_date',
'target_date',
'released_at',
'created_by_user_id',
'lead_user_id',
'linked_collection_id',
'linked_featured_artwork_id',
'pinned_post_id',
];
protected $casts = [
'start_date' => 'date',
'target_date' => 'date',
'released_at' => 'datetime',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function lead(): BelongsTo
{
return $this->belongsTo(User::class, 'lead_user_id');
}
public function linkedCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'linked_collection_id');
}
public function featuredArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'linked_featured_artwork_id');
}
public function pinnedPost(): BelongsTo
{
return $this->belongsTo(GroupPost::class, 'pinned_post_id');
}
public function artworkLinks(): HasMany
{
return $this->hasMany(GroupProjectArtwork::class);
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'group_project_artworks')
->withPivot(['sort_order'])
->withTimestamps()
->orderBy('group_project_artworks.sort_order');
}
public function memberLinks(): HasMany
{
return $this->hasMany(GroupProjectMember::class);
}
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class, 'group_project_members')
->withPivot(['role_label', 'is_lead'])
->withTimestamps();
}
public function assets(): HasMany
{
return $this->hasMany(GroupAsset::class, 'linked_project_id');
}
public function milestones(): HasMany
{
return $this->hasMany(GroupProjectMilestone::class)->orderBy('sort_order');
}
public function releases(): HasMany
{
return $this->hasMany(GroupRelease::class, 'linked_project_id');
}
public function challenges(): HasMany
{
return $this->hasMany(GroupChallenge::class, 'linked_project_id');
}
public function events(): HasMany
{
return $this->hasMany(GroupEvent::class, 'linked_project_id');
}
public function canBeViewedBy(?User $viewer): bool
{
if ($this->visibility !== self::VISIBILITY_PRIVATE) {
return $this->group->canBeViewedBy($viewer);
}
return $viewer !== null && $this->group->canViewStudio($viewer);
}
public function coverUrl(): ?string
{
$path = trim((string) $this->cover_path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

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 GroupProjectArtwork extends Model
{
use HasFactory;
protected $fillable = [
'group_project_id',
'artwork_id',
'sort_order',
];
public function project(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'group_project_id');
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,35 @@
<?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 GroupProjectMember extends Model
{
use HasFactory;
protected $fillable = [
'group_project_id',
'user_id',
'role_label',
'is_lead',
];
protected $casts = [
'is_lead' => 'boolean',
];
public function project(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'group_project_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

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 GroupProjectMilestone extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'group_project_id',
'title',
'summary',
'status',
'due_date',
'owner_user_id',
'sort_order',
'notes',
];
protected $casts = [
'due_date' => 'date',
];
public function project(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'group_project_id');
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_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 GroupRecruitmentProfile extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'is_recruiting',
'headline',
'description',
'roles_json',
'skills_json',
'contact_mode',
'visibility',
];
protected $casts = [
'is_recruiting' => 'boolean',
'roles_json' => 'array',
'skills_json' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
}

View File

@@ -0,0 +1,156 @@
<?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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class GroupRelease extends Model
{
use HasFactory;
use SoftDeletes;
public const STATUS_PLANNED = 'planned';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_INTERNAL_REVIEW = 'internal_review';
public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_RELEASED = 'released';
public const STATUS_ARCHIVED = 'archived';
public const STATUS_CANCELLED = 'cancelled';
public const STAGE_CONCEPT = 'concept';
public const STAGE_PRODUCTION = 'production';
public const STAGE_REVIEW = 'review';
public const STAGE_PACKAGING = 'packaging';
public const STAGE_APPROVAL = 'approval';
public const STAGE_PUBLISHING = 'publishing';
public const STAGE_RELEASED = 'released';
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_UNLISTED = 'unlisted';
public const VISIBILITY_PRIVATE = 'private';
protected $fillable = [
'group_id',
'title',
'slug',
'summary',
'description',
'cover_path',
'status',
'current_stage',
'visibility',
'planned_release_at',
'released_at',
'lead_user_id',
'linked_project_id',
'linked_collection_id',
'featured_artwork_id',
'release_notes',
'created_by_user_id',
'published_at',
'is_featured',
];
protected $casts = [
'planned_release_at' => 'datetime',
'released_at' => 'datetime',
'published_at' => 'datetime',
'is_featured' => 'boolean',
];
public function getRouteKeyName(): string
{
return 'slug';
}
public function group(): BelongsTo
{
return $this->belongsTo(Group::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function lead(): BelongsTo
{
return $this->belongsTo(User::class, 'lead_user_id');
}
public function linkedProject(): BelongsTo
{
return $this->belongsTo(GroupProject::class, 'linked_project_id');
}
public function linkedCollection(): BelongsTo
{
return $this->belongsTo(Collection::class, 'linked_collection_id');
}
public function featuredArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'featured_artwork_id');
}
public function artworkLinks(): HasMany
{
return $this->hasMany(GroupReleaseArtwork::class)->orderBy('sort_order');
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'group_release_artworks')
->withPivot(['sort_order'])
->withTimestamps()
->orderBy('group_release_artworks.sort_order');
}
public function contributorLinks(): HasMany
{
return $this->hasMany(GroupReleaseContributor::class)->orderBy('sort_order');
}
public function contributors(): BelongsToMany
{
return $this->belongsToMany(User::class, 'group_release_contributors')
->withPivot(['role_label', 'sort_order'])
->withTimestamps();
}
public function milestones(): HasMany
{
return $this->hasMany(GroupReleaseMilestone::class)->orderBy('sort_order');
}
public function canBeViewedBy(?User $viewer): bool
{
if ($this->visibility !== self::VISIBILITY_PRIVATE) {
return $this->group->canBeViewedBy($viewer);
}
return $viewer !== null && $this->group->canViewStudio($viewer);
}
public function coverUrl(): ?string
{
$path = trim((string) $this->cover_path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

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 GroupReleaseArtwork extends Model
{
use HasFactory;
protected $fillable = [
'group_release_id',
'artwork_id',
'sort_order',
];
public function release(): BelongsTo
{
return $this->belongsTo(GroupRelease::class, 'group_release_id');
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::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 GroupReleaseContributor extends Model
{
use HasFactory;
protected $fillable = [
'group_release_id',
'user_id',
'role_label',
'sort_order',
];
public function release(): BelongsTo
{
return $this->belongsTo(GroupRelease::class, 'group_release_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

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 GroupReleaseMilestone extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'group_release_id',
'title',
'summary',
'status',
'due_date',
'owner_user_id',
'sort_order',
'notes',
];
protected $casts = [
'due_date' => 'date',
];
public function release(): BelongsTo
{
return $this->belongsTo(GroupRelease::class, 'group_release_id');
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
}

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;
class Leaderboard extends Model
{
use HasFactory;
public const TYPE_CREATOR = 'creator';
public const TYPE_ARTWORK = 'artwork';
public const TYPE_GROUP = 'group';
public const TYPE_STORY = 'story';
public const PERIOD_DAILY = 'daily';
public const PERIOD_WEEKLY = 'weekly';
public const PERIOD_MONTHLY = 'monthly';
public const PERIOD_ALL_TIME = 'all_time';
protected $fillable = [
'type',
'entity_id',
'score',
'period',
];
protected function casts(): array
{
return [
'entity_id' => 'integer',
'score' => 'float',
];
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
use App\Models\MessageRead;
/**
* @property int $id
* @property int $conversation_id
* @property int $sender_id
* @property string $body
* @property \Carbon\Carbon|null $edited_at
* @property \Carbon\Carbon|null $deleted_at
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class Message extends Model
{
use HasFactory, SoftDeletes, Searchable;
protected $fillable = [
'uuid',
'client_temp_id',
'conversation_id',
'sender_id',
'message_type',
'body',
'meta_json',
'reply_to_message_id',
'edited_at',
];
protected $casts = [
'meta_json' => 'array',
'edited_at' => 'datetime',
];
protected static function booted(): void
{
static::creating(function (self $message): void {
if (! $message->uuid) {
$message->uuid = (string) Str::uuid();
}
});
}
// ── Relationships ────────────────────────────────────────────────────────
public function conversation(): BelongsTo
{
return $this->belongsTo(Conversation::class);
}
public function sender(): BelongsTo
{
return $this->belongsTo(User::class, 'sender_id');
}
public function reactions(): HasMany
{
return $this->hasMany(MessageReaction::class);
}
public function attachments(): HasMany
{
return $this->hasMany(MessageAttachment::class);
}
public function reads(): HasMany
{
return $this->hasMany(MessageRead::class);
}
public function setBodyAttribute(?string $value): void
{
$sanitized = trim(strip_tags((string) $value));
$this->attributes['body'] = $sanitized;
}
public function searchableAs(): string
{
return config('messaging.search.index', 'messages');
}
public function shouldBeSearchable(): bool
{
return $this->deleted_at === null;
}
public function toSearchableArray(): array
{
return [
'id' => (int) $this->id,
'conversation_id' => (int) $this->conversation_id,
'sender_id' => (int) $this->sender_id,
'sender_username' => (string) ($this->sender?->username ?? ''),
'body_text' => trim(strip_tags((string) $this->body)),
'created_at' => optional($this->created_at)->timestamp ?? now()->timestamp,
'has_attachments' => $this->relationLoaded('attachments')
? $this->attachments->isNotEmpty()
: $this->attachments()->exists(),
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MessageAttachment extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'message_id',
'disk',
'user_id',
'type',
'mime',
'size_bytes',
'width',
'height',
'sha256',
'original_name',
'storage_path',
'created_at',
];
protected $casts = [
'size_bytes' => 'integer',
'width' => 'integer',
'height' => 'integer',
'created_at' => 'datetime',
];
public function message(): BelongsTo
{
return $this->belongsTo(Message::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $message_id
* @property int $user_id
* @property string $reaction
* @property \Carbon\Carbon $created_at
*/
class MessageReaction extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'message_id',
'user_id',
'reaction',
];
protected $casts = [
'created_at' => 'datetime',
];
// ── Relationships ────────────────────────────────────────────────────────
public function message(): BelongsTo
{
return $this->belongsTo(Message::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MessageRead extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'message_id',
'user_id',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function message(): BelongsTo
{
return $this->belongsTo(Message::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

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 Notification extends Model
{
use HasFactory;
protected $table = 'notifications';
protected $fillable = [
'user_id',
'type',
'data',
'read_at',
];
protected $casts = [
'data' => 'array',
'read_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function markAsRead(): void
{
if ($this->read_at === null) {
$this->forceFill(['read_at' => now()])->save();
}
}
}

Some files were not shown because too many files have changed in this diff Show More