Save workspace changes
This commit is contained in:
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')]);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user