messages implemented

This commit is contained in:
2026-02-26 21:12:32 +01:00
parent d0aefc5ddc
commit 15b7b77d20
168 changed files with 14728 additions and 6786 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
View 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();
}
}

View 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
View 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(),
];
}
}

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

View File

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

26
app/Models/Report.php Normal file
View 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');
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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;