Save workspace changes

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

View File

@@ -0,0 +1,424 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Models\Group;
use App\Models\GroupFollow;
use App\Models\GroupInvitation;
use App\Models\GroupMember;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\SocialAccount;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\Notification;
use App\Models\Achievement;
use App\Models\UserAchievement;
use App\Models\UserActivity;
use App\Models\UserXpLog;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Laravel\Scout\Searchable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, SoftDeletes;
use Searchable {
Searchable::bootSearchable as private bootScoutSearchable;
}
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'username',
'username_changed_at',
'last_username_change_at',
'onboarding_step',
'name',
'email',
'last_verification_sent_at',
'verification_send_count_24h',
'verification_send_window_started_at',
'is_active',
'needs_password_reset',
'cover_hash',
'cover_ext',
'cover_position',
'trust_score',
'bot_risk_score',
'bot_flags',
'last_bot_activity_at',
'spam_reports',
'approved_posts',
'flagged_posts',
'xp',
'level',
'rank',
'password',
'role',
'nova_featured_creator',
'allow_messages_from',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_visit_at' => 'datetime',
'last_verification_sent_at' => 'datetime',
'verification_send_window_started_at' => 'datetime',
'verification_send_count_24h' => 'integer',
'username_changed_at' => 'datetime',
'last_username_change_at' => 'datetime',
'deleted_at' => 'datetime',
'cover_position' => 'integer',
'trust_score' => 'integer',
'bot_risk_score' => 'integer',
'bot_flags' => 'array',
'last_bot_activity_at' => 'datetime',
'spam_reports' => 'integer',
'approved_posts' => 'integer',
'flagged_posts' => 'integer',
'xp' => 'integer',
'level' => 'integer',
'rank' => 'string',
'nova_featured_creator' => 'boolean',
'password' => 'hashed',
'allow_messages_from' => 'string',
];
}
public function novaCards(): HasMany
{
return $this->hasMany(NovaCard::class);
}
public function artworks(): HasMany
{
return $this->hasMany(Artwork::class);
}
public function givenArtworkMedals(): HasMany
{
return $this->hasMany(ArtworkMedal::class, 'user_id');
}
public function collections(): HasMany
{
return $this->hasMany(Collection::class)->latest('updated_at');
}
public function ownedGroups(): HasMany
{
return $this->hasMany(Group::class, 'owner_user_id')->latest('updated_at');
}
public function groupMemberships(): HasMany
{
return $this->hasMany(GroupMember::class)->latest('updated_at');
}
public function groupInvitations(): HasMany
{
return $this->hasMany(GroupInvitation::class, 'invited_user_id')->latest('updated_at');
}
public function groupFollows(): HasMany
{
return $this->hasMany(GroupFollow::class)->latest('updated_at');
}
public function savedCollectionLists(): HasMany
{
return $this->hasMany(CollectionSavedList::class, 'user_id')->orderBy('title');
}
public function socialAccounts(): HasMany
{
return $this->hasMany(SocialAccount::class);
}
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class, 'user_id');
}
public function dashboardPreference(): HasOne
{
return $this->hasOne(DashboardPreference::class, 'user_id');
}
public function country(): BelongsTo
{
return $this->belongsTo(Country::class);
}
public function statistics(): HasOne
{
return $this->hasOne(UserStatistic::class, 'user_id');
}
/** Users that follow this user */
public function followers(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'user_followers',
'user_id',
'follower_id'
)->withPivot('created_at');
}
/** Users that this user follows */
public function following(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'user_followers',
'follower_id',
'user_id'
)->withPivot('created_at');
}
public function profileComments(): HasMany
{
return $this->hasMany(ProfileComment::class, 'profile_user_id');
}
public function xpLogs(): HasMany
{
return $this->hasMany(UserXpLog::class, 'user_id');
}
public function userAchievements(): HasMany
{
return $this->hasMany(UserAchievement::class, 'user_id');
}
public function userActivities(): HasMany
{
return $this->hasMany(UserActivity::class, 'user_id');
}
public function achievements(): BelongsToMany
{
return $this->belongsToMany(Achievement::class, 'user_achievements', 'user_id', 'achievement_id')
->withPivot('unlocked_at');
}
// ── Messaging ────────────────────────────────────────────────────────────
public function conversations(): BelongsToMany
{
return $this->belongsToMany(Conversation::class, 'conversation_participants')
->withPivot(['role', 'last_read_at', 'is_muted', 'is_archived', 'is_pinned', 'pinned_at', 'joined_at', 'left_at'])
->wherePivotNull('left_at')
->orderByPivot('joined_at', 'desc');
}
public function conversationParticipants(): HasMany
{
return $this->hasMany(ConversationParticipant::class);
}
public function sentMessages(): HasMany
{
return $this->hasMany(Message::class, 'sender_id');
}
/**
* Skinbase notifications are keyed by user_id (non-polymorphic table).
*/
public function notifications(): HasMany
{
return $this->hasMany(Notification::class, 'user_id')->latest();
}
public function unreadNotifications(): HasMany
{
return $this->notifications()->whereNull('read_at');
}
/**
* Check if this user allows receiving messages from the given user.
*/
public function allowsMessagesFrom(User $sender): bool
{
$pref = $this->allow_messages_from ?? 'everyone';
return match ($pref) {
'everyone' => true,
'followers' => $this->followers()->where('follower_id', $sender->id)->exists(),
'mutual_followers' => $this->followers()->where('follower_id', $sender->id)->exists()
&& $this->following()->where('user_id', $sender->id)->exists(),
'nobody' => false,
default => true,
};
}
// ────────────────────────────────────────────────────────────────────────
/** Artworks this user has added to their favourites. */
public function favouriteArtworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'artwork_favourites', 'user_id', 'artwork_id')
->withPivot('legacy_id')
->withTimestamps();
}
public function hasRole(string $role): bool
{
return strtolower((string) ($this->role ?? '')) === strtolower($role);
}
private function hasLegacyPrivilegeFlag(string $attribute): bool
{
if (! array_key_exists($attribute, $this->getAttributes())) {
return false;
}
return (bool) $this->getAttribute($attribute);
}
// ─── Follow helpers ───────────────────────────────────────────────────────
/**
* Whether $viewerId is following this user.
* Uses a single indexed lookup safe to call on every profile render.
*/
public function isFollowedBy(int $viewerId): bool
{
if ($viewerId === $this->id) {
return false;
}
return DB::table('user_followers')
->where('user_id', $this->id)
->where('follower_id', $viewerId)
->exists();
}
/**
* Cached follower count from user_statistics.
* Returns 0 if the statistics row does not exist yet.
*/
public function getFollowersCountAttribute(): int
{
return (int) ($this->statistics?->followers_count ?? 0);
}
/**
* Cached following count from user_statistics.
*/
public function getFollowingCountAttribute(): int
{
return (int) ($this->statistics?->following_count ?? 0);
}
public function isAdmin(): bool
{
return $this->hasRole('admin') || $this->hasLegacyPrivilegeFlag('isAdmin');
}
public function isModerator(): bool
{
return $this->hasRole('moderator') || $this->hasLegacyPrivilegeFlag('isModerator');
}
public function posts(): HasMany
{
return $this->hasMany(Post::class)->orderByDesc('created_at');
}
public function stories(): HasMany
{
return $this->hasMany(Story::class, 'creator_id')->orderByDesc('published_at');
}
// ─── Meilisearch ──────────────────────────────────────────────────────────
/**
* User indexing is handled explicitly through IndexUserJob so unrelated
* model writes do not enqueue Scout sync jobs implicitly.
*/
protected static function bootSearchable(): void
{
// Register the SearchableScope so that the Builder::searchable() macro
// is available (needed for scout:import and manual ::searchable() calls).
// We intentionally skip static::observe(new ModelObserver) so that
// User saves/deletes do not auto-enqueue Scout sync jobs.
static::addGlobalScope(new \Laravel\Scout\SearchableScope);
$whenBootedCallback = function () {
(new static)->registerSearchableMacros();
};
if (method_exists(static::class, 'whenBooted')) {
static::whenBooted($whenBootedCallback);
} else {
$whenBootedCallback();
}
}
/**
* Only index active users (not soft-deleted, is_active = true).
*/
public function shouldBeSearchable(): bool
{
return (bool) $this->is_active && ! $this->trashed();
}
/**
* Data indexed in Meilisearch.
* Includes all v2 stat counters for top-creator sorting.
*/
public function toSearchableArray(): array
{
$stats = $this->statistics;
return [
'id' => $this->id,
'username' => strtolower((string) ($this->username ?? '')),
'name' => $this->name,
// Upload activity
'uploads_count' => (int) ($stats?->uploads_count ?? 0),
// Creator-received metrics
'downloads_received_count' => (int) ($stats?->downloads_received_count ?? 0),
'artwork_views_received_count' => (int) ($stats?->artwork_views_received_count ?? 0),
'awards_received_count' => (int) ($stats?->awards_received_count ?? 0),
'favorites_received_count' => (int) ($stats?->favorites_received_count ?? 0),
'comments_received_count' => (int) ($stats?->comments_received_count ?? 0),
'reactions_received_count' => (int) ($stats?->reactions_received_count ?? 0),
// Social
'followers_count' => (int) ($stats?->followers_count ?? 0),
'following_count' => (int) ($stats?->following_count ?? 0),
'created_at' => $this->created_at?->toISOString(),
];
}
}