messages implemented
This commit is contained in:
@@ -174,6 +174,20 @@ class Artwork extends Model
|
||||
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);
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
<?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 User $user
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|CommentReaction[] $reactions
|
||||
*/
|
||||
class ArtworkComment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'artwork_comments';
|
||||
|
||||
@@ -22,6 +32,8 @@ class ArtworkComment extends Model
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'content',
|
||||
'raw_content',
|
||||
'rendered_content',
|
||||
'is_approved',
|
||||
];
|
||||
|
||||
@@ -38,4 +50,24 @@ class ArtworkComment extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
48
app/Models/ArtworkFavourite.php
Normal file
48
app/Models/ArtworkFavourite.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Represents a user's "favourite" bookmark on an artwork.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $artwork_id
|
||||
* @property int|null $legacy_id Original favourite_id from the old site
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*
|
||||
* @property-read User $user
|
||||
* @property-read Artwork $artwork
|
||||
*/
|
||||
class ArtworkFavourite extends Model
|
||||
{
|
||||
protected $table = 'artwork_favourites';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'artwork_id',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'artwork_id' => 'integer',
|
||||
'legacy_id' => 'integer',
|
||||
];
|
||||
|
||||
// ── Relations ──────────────────────────────────────────────────────────
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
}
|
||||
37
app/Models/ArtworkReaction.php
Normal file
37
app/Models/ArtworkReaction.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property int $user_id
|
||||
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
|
||||
*/
|
||||
class ArtworkReaction extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'artwork_reactions';
|
||||
|
||||
protected $fillable = ['artwork_id', 'user_id', 'reaction'];
|
||||
|
||||
protected $casts = [
|
||||
'artwork_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
37
app/Models/CommentReaction.php
Normal file
37
app/Models/CommentReaction.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $comment_id
|
||||
* @property int $user_id
|
||||
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
|
||||
*/
|
||||
class CommentReaction extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'comment_reactions';
|
||||
|
||||
protected $fillable = ['comment_id', 'user_id', 'reaction'];
|
||||
|
||||
protected $casts = [
|
||||
'comment_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function comment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ArtworkComment::class, 'comment_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
117
app/Models/Conversation.php
Normal file
117
app/Models/Conversation.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?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 = [
|
||||
'type',
|
||||
'title',
|
||||
'created_by',
|
||||
'last_message_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_message_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── 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')
|
||||
->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_at) {
|
||||
$query->where('created_at', '>', $participant->last_read_at);
|
||||
}
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
}
|
||||
62
app/Models/ConversationParticipant.php
Normal file
62
app/Models/ConversationParticipant.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?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',
|
||||
'is_muted',
|
||||
'is_archived',
|
||||
'is_pinned',
|
||||
'pinned_at',
|
||||
'joined_at',
|
||||
'left_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_read_at' => 'datetime',
|
||||
'is_muted' => 'boolean',
|
||||
'is_archived' => 'boolean',
|
||||
'is_pinned' => '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);
|
||||
}
|
||||
}
|
||||
89
app/Models/Message.php
Normal file
89
app/Models/Message.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?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 Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
* @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 = [
|
||||
'conversation_id',
|
||||
'sender_id',
|
||||
'body',
|
||||
'edited_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'edited_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── 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 setBodyAttribute(string $value): void
|
||||
{
|
||||
$sanitized = trim(strip_tags($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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Models/MessageAttachment.php
Normal file
45
app/Models/MessageAttachment.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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',
|
||||
'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);
|
||||
}
|
||||
}
|
||||
43
app/Models/MessageReaction.php
Normal file
43
app/Models/MessageReaction.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $message_id
|
||||
* @property int $user_id
|
||||
* @property string $reaction
|
||||
* @property \Carbon\Carbon $created_at
|
||||
*/
|
||||
class MessageReaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'message_id',
|
||||
'user_id',
|
||||
'reaction',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────────────────
|
||||
|
||||
public function message(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Message::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
26
app/Models/Report.php
Normal file
26
app/Models/Report.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Report extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'reporter_id',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'reason',
|
||||
'details',
|
||||
'status',
|
||||
];
|
||||
|
||||
public function reporter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reporter_id');
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,19 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
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 HasFactory, Notifiable, SoftDeletes, Searchable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -34,6 +39,7 @@ class User extends Authenticatable
|
||||
'needs_password_reset',
|
||||
'password',
|
||||
'role',
|
||||
'allow_messages_from',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -61,6 +67,7 @@ class User extends Authenticatable
|
||||
'username_changed_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'allow_messages_from' => 'string',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -106,11 +113,93 @@ class User extends Authenticatable
|
||||
return $this->hasMany(ProfileComment::class, 'profile_user_id');
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// ─── 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');
|
||||
@@ -120,4 +209,42 @@ class User extends Authenticatable
|
||||
{
|
||||
return $this->hasRole('moderator');
|
||||
}
|
||||
|
||||
// ─── Meilisearch ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* User Statistics – v2 schema.
|
||||
*
|
||||
* Single row per user (user_id is PK).
|
||||
* All counters are unsignedBigInteger with default 0.
|
||||
*
|
||||
* All updates MUST go through App\Services\UserStatsService.
|
||||
*/
|
||||
class UserStatistic extends Model
|
||||
{
|
||||
protected $table = 'user_statistics';
|
||||
@@ -14,11 +24,44 @@ class UserStatistic extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'uploads',
|
||||
'downloads',
|
||||
'pageviews',
|
||||
'awards',
|
||||
'profile_views',
|
||||
|
||||
// Creator upload activity
|
||||
'uploads_count',
|
||||
|
||||
// Creator-received metrics
|
||||
'downloads_received_count',
|
||||
'artwork_views_received_count',
|
||||
'awards_received_count',
|
||||
'favorites_received_count',
|
||||
'comments_received_count',
|
||||
'reactions_received_count',
|
||||
|
||||
// Social stats (managed by FollowService)
|
||||
'followers_count',
|
||||
'following_count',
|
||||
|
||||
// Profile / discovery
|
||||
'profile_views_count',
|
||||
|
||||
// Activity timestamps
|
||||
'last_upload_at',
|
||||
'last_active_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'uploads_count' => 'integer',
|
||||
'downloads_received_count' => 'integer',
|
||||
'artwork_views_received_count' => 'integer',
|
||||
'awards_received_count' => 'integer',
|
||||
'favorites_received_count' => 'integer',
|
||||
'comments_received_count' => 'integer',
|
||||
'reactions_received_count' => 'integer',
|
||||
'followers_count' => 'integer',
|
||||
'following_count' => 'integer',
|
||||
'profile_views_count' => 'integer',
|
||||
'last_upload_at' => 'datetime',
|
||||
'last_active_at' => 'datetime',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
Reference in New Issue
Block a user