Save workspace changes
This commit is contained in:
46
.deploy/artwork-evolution-release/app/Models/Achievement.php
Normal file
46
.deploy/artwork-evolution-release/app/Models/Achievement.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
581
.deploy/artwork-evolution-release/app/Models/Artwork.php
Normal file
581
.deploy/artwork-evolution-release/app/Models/Artwork.php
Normal 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')]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class ArtworkMedal extends ArtworkAward
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class ArtworkMedalStat extends ArtworkAwardStat
|
||||
{
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
71
.deploy/artwork-evolution-release/app/Models/BlogPost.php
Normal file
71
.deploy/artwork-evolution-release/app/Models/BlogPost.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
.deploy/artwork-evolution-release/app/Models/BugReport.php
Normal file
28
.deploy/artwork-evolution-release/app/Models/BugReport.php
Normal 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);
|
||||
}
|
||||
}
|
||||
204
.deploy/artwork-evolution-release/app/Models/Category.php
Normal file
204
.deploy/artwork-evolution-release/app/Models/Category.php
Normal 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;
|
||||
}
|
||||
}
|
||||
24
.deploy/artwork-evolution-release/app/Models/CategorySeo.php
Normal file
24
.deploy/artwork-evolution-release/app/Models/CategorySeo.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
736
.deploy/artwork-evolution-release/app/Models/Collection.php
Normal file
736
.deploy/artwork-evolution-release/app/Models/Collection.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
86
.deploy/artwork-evolution-release/app/Models/ContentType.php
Normal file
86
.deploy/artwork-evolution-release/app/Models/ContentType.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
127
.deploy/artwork-evolution-release/app/Models/Conversation.php
Normal file
127
.deploy/artwork-evolution-release/app/Models/Conversation.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
88
.deploy/artwork-evolution-release/app/Models/Country.php
Normal file
88
.deploy/artwork-evolution-release/app/Models/Country.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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) : [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
100
.deploy/artwork-evolution-release/app/Models/ForumPost.php
Normal file
100
.deploy/artwork-evolution-release/app/Models/ForumPost.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
63
.deploy/artwork-evolution-release/app/Models/ForumThread.php
Normal file
63
.deploy/artwork-evolution-release/app/Models/ForumThread.php
Normal 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);
|
||||
}
|
||||
}
|
||||
868
.deploy/artwork-evolution-release/app/Models/Group.php
Normal file
868
.deploy/artwork-evolution-release/app/Models/Group.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
87
.deploy/artwork-evolution-release/app/Models/GroupAsset.php
Normal file
87
.deploy/artwork-evolution-release/app/Models/GroupAsset.php
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
31
.deploy/artwork-evolution-release/app/Models/GroupBadge.php
Normal file
31
.deploy/artwork-evolution-release/app/Models/GroupBadge.php
Normal 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);
|
||||
}
|
||||
}
|
||||
125
.deploy/artwork-evolution-release/app/Models/GroupChallenge.php
Normal file
125
.deploy/artwork-evolution-release/app/Models/GroupChallenge.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
118
.deploy/artwork-evolution-release/app/Models/GroupEvent.php
Normal file
118
.deploy/artwork-evolution-release/app/Models/GroupEvent.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
29
.deploy/artwork-evolution-release/app/Models/GroupFollow.php
Normal file
29
.deploy/artwork-evolution-release/app/Models/GroupFollow.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
51
.deploy/artwork-evolution-release/app/Models/GroupMember.php
Normal file
51
.deploy/artwork-evolution-release/app/Models/GroupMember.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
59
.deploy/artwork-evolution-release/app/Models/GroupPost.php
Normal file
59
.deploy/artwork-evolution-release/app/Models/GroupPost.php
Normal 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');
|
||||
}
|
||||
}
|
||||
162
.deploy/artwork-evolution-release/app/Models/GroupProject.php
Normal file
162
.deploy/artwork-evolution-release/app/Models/GroupProject.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
156
.deploy/artwork-evolution-release/app/Models/GroupRelease.php
Normal file
156
.deploy/artwork-evolution-release/app/Models/GroupRelease.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
38
.deploy/artwork-evolution-release/app/Models/Leaderboard.php
Normal file
38
.deploy/artwork-evolution-release/app/Models/Leaderboard.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
112
.deploy/artwork-evolution-release/app/Models/Message.php
Normal file
112
.deploy/artwork-evolution-release/app/Models/Message.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
34
.deploy/artwork-evolution-release/app/Models/MessageRead.php
Normal file
34
.deploy/artwork-evolution-release/app/Models/MessageRead.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user