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

@@ -223,11 +223,11 @@ class ImportLegacyUsers extends Command
DB::table('user_statistics')->updateOrInsert(
['user_id' => $legacyId],
[
'uploads' => $uploads,
'downloads' => $downloads,
'pageviews' => $pageviews,
'awards' => $awards,
'updated_at' => $now,
'uploads_count' => $uploads,
'downloads_received_count' => $downloads,
'artwork_views_received_count' => $pageviews,
'awards_received_count' => $awards,
'updated_at' => $now,
]
);

View File

@@ -0,0 +1,325 @@
<?php
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* php artisan skinbase:migrate-favourites
*
* Migrates rows from the legacy `favourites` table (projekti_old_skinbase)
* into the new `artwork_favourites` table on the default connection.
*
* Skipped rows (logged as warnings):
* - artwork_id not found in new artworks table
* - user_id not found in new OR legacy users table (unless --import-missing-users)
* - row already imported (duplicate legacy_id)
* - would create a duplicate (user_id, artwork_id) pair
*
* Dropped legacy columns (not migrated):
* - user_type membership tier, not relevant to the relationship
* - author_id always derivable via artworks.user_id
*
* Options:
* --dry-run Preview without writing
* --chunk=500 Rows per batch
* --start-id=0 Resume from this favourite_id
* --limit=0 Stop after N inserts (0 = no limit)
* --import-missing-users Auto-create a stub user from legacy data when the
* user is missing from the new DB (needs_password_reset=true)
* --legacy-connection Override legacy DB connection name (default: legacy)
* --legacy-table Override legacy favourites table name (default: favourites)
* --legacy-users-table Override legacy users table name (default: users)
*/
class MigrateFavourites extends Command
{
protected $signature = 'skinbase:migrate-favourites
{--dry-run : Preview changes without writing to the database}
{--chunk=500 : Number of rows to process per batch}
{--start-id=0 : Resume processing from this favourite_id}
{--limit=0 : Stop after inserting this many rows (0 = unlimited)}
{--import-missing-users : Auto-create stub users from legacy data when missing from new DB}
{--legacy-connection=legacy : Name of the legacy DB connection}
{--legacy-table=favourites : Name of the legacy favourites table}
{--legacy-users-table=users : Name of the legacy users table}';
protected $description = 'Migrate legacy favourites into artwork_favourites.';
// ── Counters ─────────────────────────────────────────────────────────────
private int $inserted = 0;
private int $skipped = 0;
private int $total = 0;
private int $usersImported = 0;
// ── Runtime config (set in handle()) ─────────────────────────────────────
private bool $importMissingUsers = false;
private string $legacyConn = 'legacy';
private string $legacyUsersTable = 'users';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$startId = max(0, (int) $this->option('start-id'));
$limit = max(0, (int) $this->option('limit'));
$this->importMissingUsers = (bool) $this->option('import-missing-users');
$this->legacyConn = (string) $this->option('legacy-connection');
$this->legacyUsersTable = (string) $this->option('legacy-users-table');
$legacyTable = (string) $this->option('legacy-table');
$this->info("Migrating <comment>{$this->legacyConn}.{$legacyTable}</comment> → <info>artwork_favourites</info>");
if ($this->importMissingUsers) {
$this->warn('--import-missing-users: stub users will be created with needs_password_reset=true.');
}
if ($dryRun) {
$this->warn('DRY-RUN mode — no rows will be written.');
}
if ($startId > 0) {
$this->line("Resuming from favourite_id >= {$startId}");
}
if ($limit > 0) {
$this->line("Will stop after {$limit} inserts.");
}
$query = DB::connection($this->legacyConn)
->table($legacyTable)
->orderBy('favourite_id');
if ($startId > 0) {
$query->where('favourite_id', '>=', $startId);
}
$query->chunkById(
$chunk,
function ($rows) use ($dryRun, $limit): bool {
foreach ($rows as $row) {
$this->total++;
if ($limit > 0 && $this->inserted >= $limit) {
return false; // stop chunking
}
if ($this->processRow($row, $dryRun) === false) {
$this->skipped++;
}
}
return true;
},
'favourite_id',
);
$this->newLine();
$this->info(sprintf(
'Done. %d scanned, %d %s, %d skipped%s.',
$this->total,
$this->inserted,
$dryRun ? 'would be inserted' : 'inserted',
$this->skipped,
$this->usersImported > 0
? ", {$this->usersImported} stub users " . ($dryRun ? 'would be ' : '') . 'created'
: '',
));
return self::SUCCESS;
}
// ── Row processing ────────────────────────────────────────────────────────
/**
* Process a single legacy row. Returns true on success, false when skipped.
*/
private function processRow(object $row, bool $dryRun): bool
{
$legacyId = (int) ($row->favourite_id ?? 0);
$artworkId = (int) ($row->artwork_id ?? 0);
$userId = (int) ($row->user_id ?? 0);
$datum = $row->datum ?? null;
// ── Validate IDs ────────────────────────────────────────────────────
if ($artworkId <= 0 || $userId <= 0) {
$this->skip($legacyId, "invalid artwork_id={$artworkId} or user_id={$userId}");
return false;
}
if (! DB::table('artworks')->where('id', $artworkId)->exists()) {
$this->skip($legacyId, "artwork #{$artworkId} not found in new DB");
return false;
}
if (! DB::table('users')->where('id', $userId)->exists()) {
if ($this->importMissingUsers) {
if (! $this->importUserStub($userId, $dryRun)) {
$this->skip($legacyId, "user #{$userId} not found in legacy DB either — skipped");
return false;
}
} else {
$this->skip($legacyId, "user #{$userId} not found in new DB (use --import-missing-users to auto-create)");
return false;
}
}
// ── Idempotency guards ───────────────────────────────────────────────
if (DB::table('artwork_favourites')->where('legacy_id', $legacyId)->exists()) {
// Already imported — silently skip (not counted as "skipped" error)
return true;
}
if (DB::table('artwork_favourites')
->where('user_id', $userId)
->where('artwork_id', $artworkId)
->exists()
) {
$this->skip($legacyId, "duplicate (user={$userId}, artwork={$artworkId}) already exists");
return false;
}
// ── Map timestamp ────────────────────────────────────────────────────
$createdAt = $this->parseDate($datum);
// ── Insert ───────────────────────────────────────────────────────────
if (! $dryRun) {
DB::table('artwork_favourites')->insert([
'user_id' => $userId,
'artwork_id' => $artworkId,
'legacy_id' => $legacyId,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
}
$this->inserted++;
if ($this->inserted % 500 === 0) {
$this->line(" {$this->inserted} inserted, {$this->skipped} skipped…");
}
return true;
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Look up $userId in the legacy users table and create a stub record in
* the new users table preserving the same primary key.
*
* The stub has:
* - needs_password_reset = true (user must reset before logging in)
* - legacy_password_algo = 'legacy' (marks imported credential)
* - is_active determined from legacy `active` flag
* - email placeholder if original email is null or already taken
*
* @return bool true = stub created (or already existed), false = not in legacy DB
*/
private function importUserStub(int $userId, bool $dryRun): bool
{
// Already exists — nothing to do.
if (DB::table('users')->where('id', $userId)->exists()) {
return true;
}
$legacyUser = DB::connection($this->legacyConn)
->table($this->legacyUsersTable)
->where('user_id', $userId)
->first();
if (! $legacyUser) {
return false;
}
// ── Map fields ──────────────────────────────────────────────────────
$username = trim((string) ($legacyUser->uname ?? '')) ?: "user_{$userId}";
// Ensure username is unique in the new DB.
if (DB::table('users')->where('username', $username)->exists()) {
$username = $username . '_' . $userId;
}
$name = trim((string) ($legacyUser->real_name ?? '')) ?: $username;
$email = trim((string) ($legacyUser->email ?? ''));
// Resolve email: use placeholder when blank or already taken.
if ($email === '' || DB::table('users')->where('email', $email)->exists()) {
$email = "legacy_{$userId}@legacy.skinbase.org";
}
$isActive = ((int) ($legacyUser->active ?? 0)) === 1;
$createdAt = $this->parseDate($legacyUser->joinDate ?? null);
$lastVisit = $this->parseDate($legacyUser->LastVisit ?? null);
$stub = [
'id' => $userId,
'username' => $username,
'name' => $name,
'email' => $email,
'password' => bcrypt(Str::random(48)), // unusable random password
'needs_password_reset' => true,
'legacy_password_algo' => 'legacy',
'is_active' => $isActive,
'role' => 'user',
'last_visit_at' => $lastVisit !== $createdAt ? $lastVisit : null,
'created_at' => $createdAt,
'updated_at' => $createdAt,
];
$msg = "Stub user created: #{$userId} ({$username}, {$email})";
if ($dryRun) {
$this->line(" [dry] {$msg}");
$this->usersImported++;
return true;
}
try {
// Force explicit ID insert — MySQL respects it even with auto_increment.
DB::table('users')->insert($stub);
$this->usersImported++;
$this->line(" <info>{$msg}</info>");
Log::info("skinbase:migrate-favourites {$msg}");
} catch (\Throwable $e) {
$err = "Failed to create stub user #{$userId}: {$e->getMessage()}";
$this->warn(" {$err}");
Log::error("skinbase:migrate-favourites {$err}");
return false;
}
return true;
}
/**
* Parse a legacy date value (DATE string / null / zero-date) to a
* full datetime string safe for MySQL.
*/
private function parseDate(mixed $value): string
{
if (empty($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
return Carbon::now()->toDateTimeString();
}
try {
return Carbon::parse((string) $value)->toDateTimeString();
} catch (\Throwable) {
return Carbon::now()->toDateTimeString();
}
}
private function skip(int $legacyId, string $reason): void
{
$msg = "SKIP favourite#{$legacyId}: {$reason}";
$this->warn(" {$msg}");
Log::warning("skinbase:migrate-favourites {$msg}");
}
}

View File

@@ -0,0 +1,351 @@
<?php
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Migrates legacy friends_list (from the legacy DB connection) into user_followers.
*
* Usage:
* php artisan skinbase:migrate-follows [--dry-run] [--chunk=1000] [--import-missing-users]
*
* Legacy table: friends_list
* user_id -> follower_id (the user who added the friend = someone who follows)
* friend_id -> user_id (the user being followed)
*
* With --import-missing-users: any user referenced in friends_list that does not
* exist in the new DB will be fetched from the legacy `users` table and created
* as a stub before the follow row is inserted.
*/
class MigrateFollows extends Command
{
protected $signature = 'skinbase:migrate-follows
{--dry-run : Simulate without writing to the database}
{--chunk=1000 : Number of rows to process per batch}
{--import-missing-users : Import unknown users from legacy DB instead of skipping them}';
protected $description = 'Migrate legacy friends_list into user_followers';
/** Cache per-run: id => true (resolved) | null (not in legacy DB) | false (import error) */
private array $legacyUserCache = [];
public function handle(): int
{
$isDryRun = (bool) $this->option('dry-run');
$chunkSize = max(1, (int) $this->option('chunk'));
$importMissing = (bool) $this->option('import-missing-users');
$this->info($isDryRun
? '🔍 Dry-run mode nothing will be written.'
: '🚀 Live mode writing to user_followers.'
);
if ($importMissing) {
$this->info('👤 --import-missing-users: orphan users will be fetched from legacy DB.');
}
try {
$totalLegacy = DB::connection('legacy')->table('friends_list')->count();
} catch (\Throwable $e) {
$this->error('Cannot read legacy friends_list: ' . $e->getMessage());
return self::FAILURE;
}
$this->info("Total rows in legacy friends_list: {$totalLegacy}");
$validUserIds = DB::table('users')->pluck('id')->flip()->all();
$stats = [
'processed' => 0,
'inserted' => 0,
'duplicates' => 0,
'self_follows' => 0,
'invalid' => 0, // total orphan rows skipped
'invalid_zero_id' => 0, // follower_id or friend_id was 0
'invalid_not_in_new' => 0, // not in new DB (--import-missing-users not used)
'invalid_not_in_legacy' => 0, // not in new DB AND not in legacy DB
'invalid_import_error' => 0, // in legacy DB but stub import failed
'users_imported' => 0,
'errors' => 0,
];
$logPath = storage_path('logs/migrate_follows.log');
$logFile = fopen($logPath, 'a');
$this->logLine($logFile, '=== migrate-follows started at ' . now()->toISOString()
. " (dry_run={$isDryRun}, import_missing={$importMissing}) ===");
$chunkNum = 0;
$reportEvery = max(1, (int) ceil($totalLegacy / $chunkSize / 10));
DB::connection('legacy')
->table('friends_list')
->orderBy('id')
->chunk($chunkSize, function ($rows) use (
$isDryRun,
$importMissing,
&$validUserIds,
&$stats,
&$chunkNum,
$reportEvery,
$totalLegacy,
$logFile
) {
$toInsert = [];
foreach ($rows as $row) {
$stats['processed']++;
$followerId = (int) ($row->user_id ?? 0);
$followedId = (int) ($row->friend_id ?? 0);
$createdAt = $row->date_added ?? now();
if ($followerId === $followedId) {
$stats['self_follows']++;
$this->logLine($logFile, "SKIP self-follow: user_id={$followerId}");
continue;
}
// Try to resolve any user_id that isn't in the new DB yet
$skipReasons = [];
$sides = ['follower' => $followerId, 'followed' => $followedId];
foreach ($sides as $role => $uid) {
if (isset($validUserIds[$uid])) {
continue; // already valid
}
if ($uid === 0) {
$skipReasons[] = "{$role}_id is 0/null";
$stats['invalid_zero_id']++;
continue;
}
if (! $importMissing) {
$skipReasons[] = "{$role}={$uid} not in users table (use --import-missing-users to auto-import)";
$stats['invalid_not_in_new']++;
continue;
}
// ensureLegacyUser returns: true = resolved, null = not in legacy, false = import error
$result = $this->ensureLegacyUser($uid, $isDryRun, $logFile);
if ($result === true) {
$validUserIds[$uid] = true;
$stats['users_imported']++;
} elseif ($result === null) {
$skipReasons[] = "{$role}={$uid} not found in legacy DB";
$stats['invalid_not_in_legacy']++;
} else {
$skipReasons[] = "{$role}={$uid} found in legacy DB but import failed";
$stats['invalid_import_error']++;
}
}
if (! isset($validUserIds[$followerId]) || ! isset($validUserIds[$followedId])) {
$stats['invalid']++;
$reason = implode('; ', $skipReasons) ?: 'unknown';
$this->logLine($logFile, "SKIP orphan [row_id={$row->id}] follower={$followerId} followed={$followedId}{$reason}");
continue;
}
$toInsert[] = [
'follower_id' => $followerId,
'user_id' => $followedId,
'created_at' => $createdAt,
];
}
if (! $isDryRun && ! empty($toInsert)) {
try {
$inserted = DB::table('user_followers')->insertOrIgnore($toInsert);
$stats['inserted'] += $inserted;
$stats['duplicates'] += count($toInsert) - $inserted;
} catch (\Throwable $e) {
$stats['errors']++;
$this->logLine($logFile, 'ERROR batch insert: ' . $e->getMessage());
}
} elseif ($isDryRun) {
$stats['inserted'] += count($toInsert);
}
$chunkNum++;
if ($chunkNum % $reportEvery === 0 || $stats['processed'] >= $totalLegacy) {
$pct = $totalLegacy > 0 ? round($stats['processed'] / $totalLegacy * 100) : 100;
$this->line(" {$stats['processed']} / {$totalLegacy} rows ({$pct}%)"
. " inserted: {$stats['inserted']}"
. " imported: {$stats['users_imported']}"
. " skipped: " . ($stats['self_follows'] + $stats['invalid']));
}
});
$this->newLine();
if (! $isDryRun) {
$this->info('Backfilling user_statistics counters...');
$this->backfillCounters();
}
$this->table(
['Metric', 'Count'],
[
['Processed', $stats['processed']],
['Inserted', $stats['inserted']],
['Duplicates (already exist)', $stats['duplicates']],
['Self-follows skipped', $stats['self_follows']],
['Users stub-imported from legacy', $stats['users_imported']],
['Invalid (orphan) — total', $stats['invalid']],
[' ↳ zero/null user_id', $stats['invalid_zero_id']],
[' ↳ not in new DB (not imported)', $stats['invalid_not_in_new']],
[' ↳ not in legacy DB either', $stats['invalid_not_in_legacy']],
[' ↳ legacy import error', $stats['invalid_import_error']],
['Errors', $stats['errors']],
]
);
$summary = "Processed={$stats['processed']} Inserted={$stats['inserted']} "
. "Duplicates={$stats['duplicates']} SelfFollows={$stats['self_follows']} "
. "UsersImported={$stats['users_imported']} Invalid={$stats['invalid']} "
. "(ZeroId={$stats['invalid_zero_id']} NotInNew={$stats['invalid_not_in_new']} "
. "NotInLegacy={$stats['invalid_not_in_legacy']} ImportError={$stats['invalid_import_error']}) "
. "Errors={$stats['errors']}";
$this->logLine($logFile, "=== DONE: {$summary} ===");
fclose($logFile);
$this->info("Log written to: {$logPath}");
return self::SUCCESS;
}
// -------------------------------------------------------------------------
/**
* Ensure a legacy user_id exists in the new `users` table.
*
* Returns:
* true user is valid (was already there, or was just imported / dry-run pretend-imported)
* null user not found in the legacy DB either cannot be imported
* false user found in legacy DB but the stub-import threw an exception
*
* Results are cached per command run to avoid redundant DB queries.
*/
private function ensureLegacyUser(int $legacyId, bool $isDryRun, $logFile): ?bool
{
if (array_key_exists($legacyId, $this->legacyUserCache)) {
return $this->legacyUserCache[$legacyId];
}
if (DB::table('users')->where('id', $legacyId)->exists()) {
return $this->legacyUserCache[$legacyId] = true;
}
$legacyUser = DB::connection('legacy')
->table('users')
->where('user_id', $legacyId)
->first();
if (! $legacyUser) {
$this->logLine($logFile, "IMPORT FAIL: user_id={$legacyId} not found in legacy DB");
return $this->legacyUserCache[$legacyId] = null;
}
if ($isDryRun) {
$this->logLine($logFile, "DRY-RUN IMPORT: would create user_id={$legacyId} uname={$legacyUser->uname}");
return $this->legacyUserCache[$legacyId] = true;
}
try {
$this->importLegacyUserStub($legacyUser);
$this->logLine($logFile, "IMPORTED user_id={$legacyId} uname={$legacyUser->uname}");
return $this->legacyUserCache[$legacyId] = true;
} catch (\Throwable $e) {
$this->logLine($logFile, "IMPORT ERROR user_id={$legacyId}: " . $e->getMessage());
return $this->legacyUserCache[$legacyId] = false;
}
}
private function importLegacyUserStub(object $row): void
{
$legacyId = (int) $row->user_id;
$now = now();
$username = UsernamePolicy::sanitizeLegacy((string) ($row->uname ?: ('user' . $legacyId)));
if (! $username) {
$username = 'user' . $legacyId;
}
if (DB::table('users')->whereRaw('LOWER(username) = ?', [strtolower($username)])->exists()) {
$username = $username . $legacyId;
}
$email = ($row->email ? strtolower(trim($row->email)) : null)
?: ('user' . $legacyId . '@users.skinbase.org');
DB::transaction(function () use ($legacyId, $username, $email, $row, $now) {
DB::table('users')->insertOrIgnore([
'id' => $legacyId,
'username' => $username,
'name' => $row->real_name ?: $username,
'email' => $email,
'password' => Hash::make(Str::random(32)),
'is_active' => (int) ($row->active ?? 1) === 1,
'needs_password_reset' => true,
'role' => 'user',
'created_at' => $row->joinDate ?? $now,
'updated_at' => $now,
]);
DB::table('user_profiles')->updateOrInsert(
['user_id' => $legacyId],
[
'country' => $row->country ?? null,
'country_code' => $row->country_code ? substr((string) $row->country_code, 0, 2) : null,
'website' => $row->web ?? null,
'updated_at' => $now,
]
);
DB::table('user_statistics')->updateOrInsert(
['user_id' => $legacyId],
['updated_at' => $now, 'created_at' => $now]
);
});
}
// -------------------------------------------------------------------------
private function backfillCounters(): void
{
DB::statement('
UPDATE user_statistics us
JOIN (
SELECT user_id, COUNT(*) AS cnt
FROM user_followers
GROUP BY user_id
) AS f ON f.user_id = us.user_id
SET us.followers_count = f.cnt, us.updated_at = NOW()
');
DB::statement('
UPDATE user_statistics us
JOIN (
SELECT follower_id, COUNT(*) AS cnt
FROM user_followers
GROUP BY follower_id
) AS f ON f.follower_id = us.user_id
SET us.following_count = f.cnt, us.updated_at = NOW()
');
$this->info('Counters backfilled.');
}
private function logLine($handle, string $message): void
{
if (is_resource($handle)) {
fwrite($handle, '[' . now()->toISOString() . '] ' . $message . PHP_EOL);
}
}
}

View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Migrates legacy `chat` / `messages` tables into the modern conversation-based system.
*
* Strategy:
* 1. Load all legacy rows from the `chat` table via the 'legacy' DB connection.
* 2. Group by (sender_user_id, receiver_user_id) pair (canonical: min first).
* 3. For each pair, find or create a `direct` conversation.
* 4. Insert each message in chronological order.
* 5. Set last_read_at based on the legacy read_date column (if present).
* 6. Skip deleted / inactive rows.
* 7. Convert smileys to emoji placeholders.
*
* Usage:
* php artisan skinbase:migrate-messages
* php artisan skinbase:migrate-messages --dry-run
* php artisan skinbase:migrate-messages --chunk=1000
*/
class MigrateMessagesCommand extends Command
{
protected $signature = 'skinbase:migrate-messages
{--dry-run : Preview only no writes to DB}
{--chunk=500 : Rows to process per batch}';
protected $description = 'Migrate legacy chat/messages into the modern conversation system';
/** Columns we attempt to read; gracefully degrade if missing. */
private array $skipped = [];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
if ($dryRun) {
$this->warn('[DRY-RUN] No data will be written.');
}
// ── Check legacy connection ───────────────────────────────────────────
try {
DB::connection('legacy')->getPdo();
} catch (Throwable $e) {
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
return self::FAILURE;
}
$legacySchema = DB::connection('legacy')->getSchemaBuilder();
if (! $legacySchema->hasTable('chat')) {
$this->error('Legacy table `chat` not found on the legacy connection.');
return self::FAILURE;
}
$columns = $legacySchema->getColumnListing('chat');
$this->info('Legacy chat columns: ' . implode(', ', $columns));
// Map expected legacy columns (adapt if your legacy schema differs)
$hasReadDate = in_array('read_date', $columns, true);
$hasSoftDelete = in_array('deleted', $columns, true);
// ── Count total rows ──────────────────────────────────────────────────
$query = DB::connection('legacy')->table('chat');
if ($hasSoftDelete) {
$query->where('deleted', 0);
}
$total = $query->count();
$this->info("Total legacy rows to process: {$total}");
if ($total === 0) {
$this->info('Nothing to migrate.');
return self::SUCCESS;
}
$bar = $this->output->createProgressBar($total);
$inserted = 0;
$skipped = 0;
$offset = 0;
// ── Chunk processing ──────────────────────────────────────────────────
while (true) {
$rows = DB::connection('legacy')
->table('chat')
->when($hasSoftDelete, fn ($q) => $q->where('deleted', 0))
->orderBy('id')
->offset($offset)
->limit($chunk)
->get();
if ($rows->isEmpty()) {
break;
}
foreach ($rows as $row) {
$senderId = (int) ($row->sender_user_id ?? $row->from_user_id ?? $row->user_id ?? 0);
$receiverId = (int) ($row->receiver_user_id ?? $row->to_user_id ?? $row->recipient_id ?? 0);
$body = trim((string) ($row->message ?? $row->body ?? $row->content ?? ''));
$createdAt = $row->created_at ?? $row->date ?? $row->timestamp ?? now();
$readDate = $hasReadDate ? $row->read_date : null;
if ($senderId === 0 || $receiverId === 0 || $body === '') {
$skipped++;
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'missing sender/receiver/body'];
$bar->advance();
continue;
}
// Skip self-messages
if ($senderId === $receiverId) {
$skipped++;
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'self-message'];
$bar->advance();
continue;
}
// Sanitize: strip HTML, convert smileys to emoji
$body = $this->sanitize($body);
if ($dryRun) {
$inserted++;
$bar->advance();
continue;
}
try {
DB::transaction(function () use ($senderId, $receiverId, $body, $createdAt, $readDate, &$inserted) {
// Find or create direct conversation
$conv = Conversation::findDirect($senderId, $receiverId);
if (! $conv) {
$conv = Conversation::create([
'type' => 'direct',
'created_by' => $senderId,
'last_message_at' => $createdAt,
]);
ConversationParticipant::insert([
[
'conversation_id' => $conv->id,
'user_id' => $senderId,
'role' => 'admin',
'joined_at' => $createdAt,
'last_read_at' => $readDate,
],
[
'conversation_id' => $conv->id,
'user_id' => $receiverId,
'role' => 'member',
'joined_at' => $createdAt,
'last_read_at' => $readDate,
],
]);
} else {
// Update last_read_at on existing participants when available
if ($readDate) {
ConversationParticipant::where('conversation_id', $conv->id)
->where('user_id', $receiverId)
->whereNull('last_read_at')
->update(['last_read_at' => $readDate]);
}
}
Message::create([
'conversation_id' => $conv->id,
'sender_id' => $senderId,
'body' => $body,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
// Keep last_message_at up to date
if ($conv->last_message_at < $createdAt) {
$conv->update(['last_message_at' => $createdAt]);
}
$inserted++;
});
} catch (Throwable $e) {
$skipped++;
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => $e->getMessage()];
Log::warning('MigrateMessages: skipped row', [
'id' => $row->id ?? '?',
'reason' => $e->getMessage(),
]);
}
$bar->advance();
}
$offset += $chunk;
}
$bar->finish();
$this->newLine();
$this->info("Done. Inserted: {$inserted} | Skipped: {$skipped}");
if ($skipped > 0 && $this->option('verbose')) {
$this->table(['ID', 'Reason'], $this->skipped);
}
return self::SUCCESS;
}
/**
* Strip HTML tags and convert common legacy smileys to emoji.
*/
private function sanitize(string $body): string
{
// Strip raw HTML
$body = strip_tags($body);
// Decode HTML entities
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Common smiley → emoji mapping
$smileys = [
':)' => '🙂', ':-)' => '🙂',
':(' => '🙁', ':-(' => '🙁',
':D' => '😀', ':-D' => '😀',
':P' => '😛', ':-P' => '😛',
';)' => '😉', ';-)' => '😉',
':o' => '😮', ':O' => '😮',
':|' => '😐', ':-|' => '😐',
':/' => '😕', ':-/' => '😕',
'<3' => '❤️',
'xD' => '😂', 'XD' => '😂',
];
return str_replace(array_keys($smileys), array_values($smileys), $body);
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Console\Commands;
use App\Services\LegacySmileyMapper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* php artisan skinbase:migrate-smileys
*
* Scans artworks.description, artwork_comments.content, and forum_posts.content,
* replaces legacy smiley codes (:beer, :lol, etc.) with Unicode emoji.
*
* Options:
* --dry-run Show what would change without writing to DB
* --chunk=200 Rows processed per batch (default 200)
* --table=artworks Limit scan to one table
*/
class MigrateSmileys extends Command
{
protected $signature = 'skinbase:migrate-smileys
{--dry-run : Preview changes without writing to the database}
{--chunk=200 : Number of rows to process per batch}
{--table= : Limit scan to a single table (artworks|artwork_comments|forum_posts)}';
protected $description = 'Convert legacy :smiley: codes to Unicode emoji in content fields.';
/** Tables and their content columns to scan. */
private const TARGETS = [
'artworks' => 'description',
'artwork_comments' => 'content',
'forum_posts' => 'content',
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$tableOpt = $this->option('table');
$targets = self::TARGETS;
if ($tableOpt) {
if (! isset($targets[$tableOpt])) {
$this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets)));
return self::FAILURE;
}
$targets = [$tableOpt => $targets[$tableOpt]];
}
if ($dryRun) {
$this->warn('DRY-RUN mode — no changes will be written.');
}
$totalChanged = 0;
$totalRows = 0;
foreach ($targets as $table => $column) {
$this->line("Scanning <info>{$table}.{$column}</info>…");
[$changed, $rows] = $this->processTable($table, $column, $chunk, $dryRun);
$totalChanged += $changed;
$totalRows += $rows;
$this->line("{$rows} rows scanned, {$changed} updated.");
}
$this->newLine();
$this->info("Summary: {$totalRows} rows scanned, {$totalChanged} rows " . ($dryRun ? 'would be ' : '') . 'updated.');
return self::SUCCESS;
}
private function processTable(
string $table,
string $column,
int $chunk,
bool $dryRun
): array {
$totalChanged = 0;
$totalRows = 0;
DB::table($table)
->whereNotNull($column)
->orderBy('id')
->chunk($chunk, function ($rows) use ($table, $column, $dryRun, &$totalChanged, &$totalRows) {
foreach ($rows as $row) {
$original = $row->$column ?? '';
$converted = LegacySmileyMapper::convert($original);
// Collapse emoji flood runs BEFORE size/DB checks so that
// rows like ":beer :beer :beer …" (×500) don't exceed MEDIUMTEXT.
$collapsed = LegacySmileyMapper::collapseFlood($converted);
if ($collapsed !== $converted) {
$beforeBytes = mb_strlen($converted, '8bit');
$afterBytes = mb_strlen($collapsed, '8bit');
$floodMsg = "[{$table}#{$row->id}] Emoji flood collapsed "
. "({$beforeBytes} bytes \u{2192} {$afterBytes} bytes).";
$this->warn(" {$floodMsg}");
Log::warning($floodMsg);
$converted = $collapsed;
}
$totalRows++;
if ($converted === $original) {
continue;
}
$totalChanged++;
$codes = LegacySmileyMapper::detect($original);
$msg = "[{$table}#{$row->id}] Converting: " . implode(', ', $codes);
$this->line(" {$msg}");
Log::info($msg);
if (! $dryRun) {
// Guard: MEDIUMTEXT max is 16,777,215 bytes.
if (mb_strlen($converted, '8bit') > 16_777_215) {
$warn = "[{$table}#{$row->id}] SKIP — converted content exceeds MEDIUMTEXT limit (" . mb_strlen($converted, '8bit') . " bytes). Row left unchanged.";
$this->warn(" {$warn}");
Log::warning($warn);
continue;
}
try {
DB::table($table)
->where('id', $row->id)
->update([$column => $converted]);
} catch (\Throwable $e) {
$err = "[{$table}#{$row->id}] DB error: {$e->getMessage()}";
$this->warn(" {$err}");
Log::error($err);
}
}
}
});
return [$totalChanged, $totalRows];
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\RecomputeUserStatsJob;
use App\Services\UserStatsService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Recompute user_statistics counters from authoritative source tables.
*
* Usage:
* # Recompute a single user (live)
* php artisan skinbase:recompute-user-stats 42
*
* # Dry-run for a single user
* php artisan skinbase:recompute-user-stats 42 --dry-run
*
* # Recompute all users in chunks of 500
* php artisan skinbase:recompute-user-stats --all --chunk=500
*
* # Recompute all users via queue (one job per chunk)
* php artisan skinbase:recompute-user-stats --all --queue
*/
class RecomputeUserStatsCommand extends Command
{
protected $signature = 'skinbase:recompute-user-stats
{user_id? : The ID of a single user to recompute}
{--all : Recompute stats for ALL non-deleted users}
{--chunk=1000 : Chunk size when --all is used}
{--dry-run : Show what would be written without saving}
{--queue : Dispatch recompute jobs to the queue (--all mode only)}';
protected $description = 'Rebuild user_statistics counters from authoritative source tables';
public function handle(UserStatsService $statsService): int
{
$dryRun = (bool) $this->option('dry-run');
$all = (bool) $this->option('all');
$userId = $this->argument('user_id');
$chunk = max(1, (int) $this->option('chunk'));
$queue = (bool) $this->option('queue');
if ($userId !== null && $all) {
$this->error('Provide either a user_id OR --all, not both.');
return self::FAILURE;
}
if ($userId !== null) {
return $this->recomputeSingle((int) $userId, $statsService, $dryRun);
}
if ($all) {
return $this->recomputeAll($statsService, $chunk, $dryRun, $queue);
}
$this->error('Provide a user_id or use --all.');
return self::FAILURE;
}
// ─── Single user ─────────────────────────────────────────────────────────
private function recomputeSingle(int $userId, UserStatsService $statsService, bool $dryRun): int
{
$exists = DB::table('users')->where('id', $userId)->exists();
if (! $exists) {
$this->error("User {$userId} not found.");
return self::FAILURE;
}
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
$this->line("{$label} Recomputing stats for user #{$userId}");
$computed = $statsService->recomputeUser($userId, $dryRun);
$rows = [];
foreach ($computed as $col => $val) {
$rows[] = [$col, $val ?? '(null)'];
}
$this->table(['Column', 'Value'], $rows);
if ($dryRun) {
$this->warn('Dry-run: no changes written.');
} else {
$this->info("Stats saved for user #{$userId}.");
}
return self::SUCCESS;
}
// ─── All users ────────────────────────────────────────────────────────────
private function recomputeAll(
UserStatsService $statsService,
int $chunk,
bool $dryRun,
bool $useQueue
): int {
$total = DB::table('users')->whereNull('deleted_at')->count();
$label = $dryRun ? '[DRY-RUN]' : ($useQueue ? '[QUEUE]' : '[LIVE]');
$this->info("{$label} Recomputing stats for {$total} users (chunk={$chunk})…");
if ($useQueue && ! $dryRun) {
$dispatched = 0;
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use (&$dispatched) {
$ids = $users->pluck('id')->all();
RecomputeUserStatsJob::dispatch($ids);
$dispatched += count($ids);
$this->line(" Queued chunk of " . count($ids) . " users (total dispatched: {$dispatched})");
});
$this->info("Done {$dispatched} users queued for recompute.");
return self::SUCCESS;
}
$processed = 0;
$bar = $this->output->createProgressBar($total);
$bar->start();
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use ($statsService, $dryRun, &$processed, $bar) {
foreach ($users as $user) {
$statsService->recomputeUser((int) $user->id, $dryRun);
$processed++;
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$suffix = $dryRun ? ' (no changes written dry-run)' : '';
$this->info("Done {$processed} users recomputed{$suffix}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Console\Commands;
use App\Services\ContentSanitizer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* php artisan skinbase:sanitize-content
*
* Scans legacy content for unsafe HTML, converts it to Markdown-safe text,
* and populates the raw_content / rendered_content columns on artwork_comments.
*
* Options:
* --dry-run Preview changes without writing
* --chunk=200 Rows per batch
* --table= Limit to one target
* --artwork-id= Limit to a single artwork (filters artwork_comments by artwork_id, artworks by id)
*/
class SanitizeContent extends Command
{
protected $signature = 'skinbase:sanitize-content
{--dry-run : Preview changes without writing to the database}
{--chunk=200 : Number of rows per batch}
{--table= : Limit scan to a single target (artwork_comments|artworks|forum_posts)}
{--artwork-id= : Limit scan to a single artwork ID (skips forum_posts)}';
protected $description = 'Strip unsafe HTML from legacy content and populate sanitized columns.';
/**
* table => [read_col, write_raw_col, write_rendered_col|null]
*
* For artwork_comments we write two columns; for the others we only sanitize in-place.
*/
private const TARGETS = [
'artwork_comments' => [
'read' => 'content',
'write_raw' => 'raw_content',
'write_rendered' => 'rendered_content',
],
'artworks' => [
'read' => 'description',
'write_raw' => 'description',
'write_rendered' => null,
],
'forum_posts' => [
'read' => 'content',
'write_raw' => 'content',
'write_rendered' => null,
],
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$chunk = max(1, (int) $this->option('chunk'));
$tableOpt = $this->option('table');
$artworkId = $this->option('artwork-id');
if ($artworkId !== null) {
if (! ctype_digit((string) $artworkId) || (int) $artworkId < 1) {
$this->error("--artwork-id must be a positive integer. Got: {$artworkId}");
return self::FAILURE;
}
$artworkId = (int) $artworkId;
}
$targets = self::TARGETS;
if ($tableOpt) {
if (! isset($targets[$tableOpt])) {
$this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets)));
return self::FAILURE;
}
$targets = [$tableOpt => $targets[$tableOpt]];
}
// --artwork-id removes forum_posts (no artwork FK) and informs the user.
if ($artworkId !== null) {
unset($targets['forum_posts']);
$this->line("Filtering to artwork <info>#{$artworkId}</info> (forum_posts skipped).");
}
if ($dryRun) {
$this->warn('DRY-RUN mode — no changes will be written.');
}
$totalModified = 0;
$totalRows = 0;
foreach ($targets as $table => $def) {
$this->line("Processing <info>{$table}</info>…");
[$modified, $rows] = $this->processTable($table, $def, $chunk, $dryRun, $artworkId);
$totalModified += $modified;
$totalRows += $rows;
$this->line("{$rows} rows scanned, {$modified} modified.");
}
$this->newLine();
$this->info("Summary: {$totalRows} rows, {$totalModified} " . ($dryRun ? 'would be ' : '') . 'modified.');
return self::SUCCESS;
}
private function processTable(
string $table,
array $def,
int $chunk,
bool $dryRun,
?int $artworkId = null
): array {
$totalModified = 0;
$totalRows = 0;
$readCol = $def['read'];
$writeRawCol = $def['write_raw'];
$writeRenderedCol = $def['write_rendered'];
DB::table($table)
->whereNotNull($readCol)
->when($artworkId !== null, function ($q) use ($table, $artworkId) {
// artwork_comments has artwork_id; artworks is filtered by its own PK.
$filterCol = $table === 'artwork_comments' ? 'artwork_id' : 'id';
$q->where($filterCol, $artworkId);
})
->orderBy('id')
->chunk($chunk, function ($rows) use (
$table, $readCol, $writeRawCol, $writeRenderedCol,
$dryRun, &$totalModified, &$totalRows
) {
foreach ($rows as $row) {
$original = $row->$readCol ?? '';
$stripped = ContentSanitizer::stripToPlain($original);
$totalRows++;
// Detect if content had HTML that we need to clean
$hadHtml = $original !== $stripped && preg_match('/<[a-z][^>]*>/i', $original);
if ($writeRawCol === $readCol && ! $hadHtml) {
// Same column, no HTML, skip
continue;
}
$rendered = ContentSanitizer::render($stripped);
$totalModified++;
if ($hadHtml) {
$this->line(" [{$table}#{$row->id}] Stripped HTML from content.");
Log::info("skinbase:sanitize-content stripped HTML from {$table}#{$row->id}");
}
if ($dryRun) {
continue;
}
$update = [$writeRawCol => $stripped];
if ($writeRenderedCol) {
$update[$writeRenderedCol] = $rendered;
}
DB::table($table)->where('id', $row->id)->update($update);
}
// Also populate rendered_content for rows that have raw_content but no rendered_content
if ($writeRenderedCol && ! $dryRun) {
DB::table($table)
->whereNotNull($writeRawCol)
->whereNull($writeRenderedCol)
->orderBy('id')
->chunk(200, function ($missing) use ($table, $writeRawCol, $writeRenderedCol) {
foreach ($missing as $row) {
$rendered = ContentSanitizer::render($row->$writeRawCol ?? '');
DB::table($table)->where('id', $row->id)->update([
$writeRenderedCol => $rendered,
]);
}
});
}
});
return [$totalModified, $totalRows];
}
}

View File

@@ -35,6 +35,7 @@ class Kernel extends ConsoleKernel
EvaluateFeedWeightsCommand::class,
CompareFeedAbCommand::class,
AiTagArtworksCommand::class,
\App\Console\Commands\MigrateFollows::class,
];
/**

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Enums;
/**
* Reaction slugs used in the database.
* Emoji are only used for display slugs are stored.
*/
enum ReactionType: string
{
case ThumbsUp = 'thumbs_up';
case Heart = 'heart';
case Fire = 'fire';
case Laugh = 'laugh';
case Clap = 'clap';
case Wow = 'wow';
/** Return the display emoji for this reaction. */
public function emoji(): string
{
return match ($this) {
self::ThumbsUp => '👍',
self::Heart => '❤️',
self::Fire => '🔥',
self::Laugh => '😂',
self::Clap => '👏',
self::Wow => '😮',
};
}
/** Human-readable label. */
public function label(): string
{
return match ($this) {
self::ThumbsUp => 'Like',
self::Heart => 'Love',
self::Fire => 'Fire',
self::Laugh => 'Haha',
self::Clap => 'Clap',
self::Wow => 'Wow',
};
}
/** All valid slugs — used for validation. */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/** Full UI payload for the frontend. */
public static function asMap(): array
{
$map = [];
foreach (self::cases() as $case) {
$map[$case->value] = [
'slug' => $case->value,
'emoji' => $case->emoji(),
'label' => $case->label(),
];
}
return $map;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent
{
use Dispatchable, SerializesModels;
public function __construct(
public int $conversationId,
public int $messageId,
public int $senderId,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TypingStarted
{
use Dispatchable, SerializesModels;
public function __construct(
public int $conversationId,
public int $userId,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TypingStopped
{
use Dispatchable, SerializesModels;
public function __construct(
public int $conversationId,
public int $userId,
) {}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Report;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class ModerationReportQueueController extends Controller
{
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', 'open');
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
$items = Report::query()
->with('reporter:id,username')
->where('status', $status)
->orderByDesc('id')
->paginate(30);
return response()->json($items);
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Services\ContentSanitizer;
use App\Services\LegacySmileyMapper;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
/**
* Artwork comment CRUD.
*
* POST /api/artworks/{artworkId}/comments store
* PUT /api/artworks/{artworkId}/comments/{id} update (own comment)
* DELETE /api/artworks/{artworkId}/comments/{id} delete (own or admin)
* GET /api/artworks/{artworkId}/comments list (paginated)
*/
class ArtworkCommentController extends Controller
{
private const MAX_LENGTH = 10_000;
// ─────────────────────────────────────────────────────────────────────────
// List
// ─────────────────────────────────────────────────────────────────────────
public function index(Request $request, int $artworkId): JsonResponse
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
$comments = ArtworkComment::with(['user', 'user.profile'])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$userId = $request->user()?->id;
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId));
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Store
// ─────────────────────────────────────────────────────────────────────────
public function store(Request $request, int $artworkId): JsonResponse
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
]);
$raw = $request->input('content');
// Validate markdown-lite content
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
}
$rendered = ContentSanitizer::render($raw);
$comment = ArtworkComment::create([
'artwork_id' => $artwork->id,
'user_id' => $request->user()->id,
'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw,
'rendered_content' => $rendered,
'is_approved' => true, // auto-approve; extend with moderation as needed
]);
// Bust the comments cache for this user's 'all' feed
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201);
}
// ─────────────────────────────────────────────────────────────────────────
// Update
// ─────────────────────────────────────────────────────────────────────────
public function update(Request $request, int $artworkId, int $commentId): JsonResponse
{
$comment = ArtworkComment::where('artwork_id', $artworkId)
->findOrFail($commentId);
Gate::authorize('update', $comment);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
]);
$raw = $request->input('content');
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
}
$rendered = ContentSanitizer::render($raw);
$comment->update([
'content' => $raw,
'raw_content' => $raw,
'rendered_content' => $rendered,
]);
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)]);
}
// ─────────────────────────────────────────────────────────────────────────
// Delete
// ─────────────────────────────────────────────────────────────────────────
public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse
{
$comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId);
Gate::authorize('delete', $comment);
$comment->delete();
Cache::forget('comments.latest.all.page1');
return response()->json(['message' => 'Comment deleted.'], 200);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function formatComment(ArtworkComment $c, ?int $currentUserId): array
{
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
return [
'id' => $c->id,
'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId,
'can_delete' => $currentUserId === $userId,
'user' => [
'id' => $userId,
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
],
];
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\FollowService;
use App\Services\UserStatsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -14,6 +16,8 @@ final class ArtworkInteractionController extends Controller
{
public function favorite(Request $request, int $artworkId): JsonResponse
{
$state = $request->boolean('state', true);
$this->toggleSimple(
request: $request,
table: 'user_favorites',
@@ -25,6 +29,18 @@ final class ArtworkInteractionController extends Controller
$this->syncArtworkStats($artworkId);
// Update creator's favorites_received_count
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if ($creatorId) {
$svc = app(UserStatsService::class);
if ($state) {
$svc->incrementFavoritesReceived($creatorId);
$svc->setLastActiveAt((int) $request->user()->id);
} else {
$svc->decrementFavoritesReceived($creatorId);
}
}
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
@@ -72,41 +88,25 @@ final class ArtworkInteractionController extends Controller
public function follow(Request $request, int $userId): JsonResponse
{
if (! Schema::hasTable('friends_list')) {
return response()->json(['message' => 'Follow unavailable'], 422);
}
$actorId = (int) $request->user()->id;
if ($actorId === $userId) {
return response()->json(['message' => 'Cannot follow yourself'], 422);
}
$svc = app(FollowService::class);
$state = $request->boolean('state', true);
$query = DB::table('friends_list')
->where('user_id', $actorId)
->where('friend_id', $userId);
if ($state) {
if (! $query->exists()) {
DB::table('friends_list')->insert([
'user_id' => $actorId,
'friend_id' => $userId,
'date_added' => now(),
]);
}
$svc->follow($actorId, $userId);
} else {
$query->delete();
$svc->unfollow($actorId, $userId);
}
$followersCount = (int) DB::table('friends_list')
->where('friend_id', $userId)
->count();
return response()->json([
'ok' => true,
'is_following' => $state,
'followers_count' => $followersCount,
'ok' => true,
'is_following' => $state,
'followers_count' => $svc->followersCount($userId),
]);
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\FollowService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* API endpoints for the follow system.
*
* POST /api/user/{username}/follow follow a user
* DELETE /api/user/{username}/follow unfollow a user
* GET /api/user/{username}/followers paginated followers list
* GET /api/user/{username}/following paginated following list
*/
final class FollowController extends Controller
{
public function __construct(private readonly FollowService $followService) {}
// ─── POST /api/user/{username}/follow ────────────────────────────────────
public function follow(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$actor = Auth::user();
if ($actor->id === $target->id) {
return response()->json(['error' => 'Cannot follow yourself.'], 422);
}
try {
$this->followService->follow((int) $actor->id, (int) $target->id);
} catch (\InvalidArgumentException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json([
'following' => true,
'followers_count' => $this->followService->followersCount((int) $target->id),
]);
}
// ─── DELETE /api/user/{username}/follow ──────────────────────────────────
public function unfollow(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$actor = Auth::user();
$this->followService->unfollow((int) $actor->id, (int) $target->id);
return response()->json([
'following' => false,
'followers_count' => $this->followService->followersCount((int) $target->id),
]);
}
// ─── GET /api/user/{username}/followers ──────────────────────────────────
public function followers(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.user_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'followed_at' => $row->followed_at,
]);
return response()->json($rows);
}
// ─── GET /api/user/{username}/following ──────────────────────────────────
public function following(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.follower_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'followed_at' => $row->followed_at,
]);
return response()->json($rows);
}
// ─── Private helpers ─────────────────────────────────────────────────────
private function resolveUser(string $username): User
{
$normalized = UsernamePolicy::normalize($username);
return User::query()
->whereRaw('LOWER(username) = ?', [$normalized])
->firstOrFail();
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Models\ArtworkComment;
use App\Support\AvatarUrl;
use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Carbon\Carbon;
class LatestCommentsApiController extends Controller
{
private const PER_PAGE = 20;
public function index(Request $request): JsonResponse
{
$type = $request->query('type', 'all');
// Validate filter type
if (! in_array($type, ['all', 'following', 'mine'], true)) {
$type = 'all';
}
// 'mine' and 'following' require auth
if (in_array($type, ['mine', 'following'], true) && ! $request->user()) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
$query = ArtworkComment::with(['user', 'user.profile', 'artwork'])
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->orderByDesc('artwork_comments.created_at');
switch ($type) {
case 'mine':
$query->where('artwork_comments.user_id', $request->user()->id);
break;
case 'following':
$followingIds = $request->user()
->following()
->pluck('users.id');
$query->whereIn('artwork_comments.user_id', $followingIds);
break;
default:
// 'all' — cache the first page only
if ((int) $request->query('page', 1) === 1) {
$cacheKey = 'comments.latest.all.page1';
$ttl = 120; // 2 minutes
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE));
} else {
$paginator = $query->paginate(self::PER_PAGE);
}
break;
}
if (! isset($paginator)) {
$paginator = $query->paginate(self::PER_PAGE);
}
$items = $paginator->getCollection()->map(function (ArtworkComment $c) {
$art = $c->artwork;
$user = $c->user;
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? ($present['url'] ?? null) : null;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
return [
'comment_id' => $c->getKey(),
'comment_text' => e(strip_tags($c->content ?? '')),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'commenter' => [
'id' => $userId,
'username' => $user?->username ?? null,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
],
'artwork' => $art ? [
'id' => $art->id,
'title' => $art->title,
'slug' => $art->slug ?? Str::slug($art->title ?? ''),
'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')),
'thumb' => $thumb,
] : null,
];
});
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'has_more' => $paginator->hasMorePages(),
],
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\MessageAttachment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AttachmentController extends Controller
{
public function show(Request $request, int $id)
{
$attachment = MessageAttachment::query()
->with('message:id,conversation_id')
->findOrFail($id);
$conversationId = (int) ($attachment->message?->conversation_id ?? 0);
abort_if($conversationId <= 0, 404, 'Attachment not available.');
$authorized = \App\Models\ConversationParticipant::query()
->where('conversation_id', $conversationId)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->exists();
abort_unless($authorized, 403, 'You are not allowed to access this attachment.');
$diskName = (string) config('messaging.attachments.disk', 'local');
$disk = Storage::disk($diskName);
return new StreamedResponse(function () use ($disk, $attachment): void {
echo $disk->get($attachment->storage_path);
}, 200, [
'Content-Type' => $attachment->mime,
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
'Content-Length' => (string) $attachment->size_bytes,
]);
}
}

View File

@@ -0,0 +1,466 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use App\Services\Messaging\MessageNotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ConversationController extends Controller
{
// ── GET /api/messages/conversations ─────────────────────────────────────
public function index(Request $request): JsonResponse
{
$user = $request->user();
$page = max(1, (int) $request->integer('page', 1));
$cacheVersion = (int) Cache::get($this->cacheVersionKey($user->id), 1);
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
return Conversation::query()
->select('conversations.*')
->join('conversation_participants as cp_me', function ($join) use ($user) {
$join->on('cp_me.conversation_id', '=', 'conversations.id')
->where('cp_me.user_id', '=', $user->id)
->whereNull('cp_me.left_at');
})
->addSelect([
'unread_count' => Message::query()
->selectRaw('count(*)')
->whereColumn('messages.conversation_id', 'conversations.id')
->where('messages.sender_id', '!=', $user->id)
->whereNull('messages.deleted_at')
->where(function ($query) {
$query->whereNull('cp_me.last_read_at')
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
}),
])
->with([
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
'latestMessage.sender:id,username',
])
->orderByDesc('cp_me.is_pinned')
->orderByDesc('cp_me.pinned_at')
->orderByDesc('last_message_at')
->orderByDesc('conversations.id')
->paginate(20, ['conversations.*'], 'page', $page);
});
$conversations->through(function ($conv) use ($user) {
$conv->my_participant = $conv->allParticipants
->firstWhere('user_id', $user->id);
return $conv;
});
return response()->json($conversations);
}
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
public function show(Request $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
$conv->load([
'allParticipants.user:id,username',
'creator:id,username',
]);
return response()->json($conv);
}
// ── POST /api/messages/conversation ─────────────────────────────────────
public function store(Request $request): JsonResponse
{
$user = $request->user();
$data = $request->validate([
'type' => 'required|in:direct,group',
'recipient_id' => 'required_if:type,direct|integer|exists:users,id',
'participant_ids' => 'required_if:type,group|array|min:2',
'participant_ids.*'=> 'integer|exists:users,id',
'title' => 'required_if:type,group|nullable|string|max:120',
'body' => 'required|string|max:5000',
]);
if ($data['type'] === 'direct') {
return $this->createDirect($request, $user, $data);
}
return $this->createGroup($request, $user, $data);
}
// ── POST /api/messages/{conversation_id}/read ────────────────────────────
public function markRead(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['last_read_at' => now()]);
$this->touchConversationCachesForUsers([$request->user()->id]);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/archive ─────────────────────────
public function archive(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['is_archived' => ! $participant->is_archived]);
$this->touchConversationCachesForUsers([$request->user()->id]);
return response()->json(['is_archived' => $participant->is_archived]);
}
// ── POST /api/messages/{conversation_id}/mute ────────────────────────────
public function mute(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['is_muted' => ! $participant->is_muted]);
$this->touchConversationCachesForUsers([$request->user()->id]);
return response()->json(['is_muted' => $participant->is_muted]);
}
public function pin(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
$this->touchConversationCachesForUsers([$request->user()->id]);
return response()->json(['is_pinned' => true]);
}
public function unpin(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
$this->touchConversationCachesForUsers([$request->user()->id]);
return response()->json(['is_pinned' => false]);
}
// ── DELETE /api/messages/{conversation_id}/leave ─────────────────────────
public function leave(Request $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
if ($conv->isGroup()) {
// Last admin protection
$adminCount = ConversationParticipant::where('conversation_id', $id)
->where('role', 'admin')
->whereNull('left_at')
->count();
if ($adminCount === 1 && $participant->role === 'admin') {
$otherMember = ConversationParticipant::where('conversation_id', $id)
->where('user_id', '!=', $request->user()->id)
->whereNull('left_at')
->first();
if ($otherMember) {
$otherMember->update(['role' => 'admin']);
}
}
}
$participant->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/add-user ────────────────────────
public function addUser(Request $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
$this->requireAdmin($request, $id);
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
$data = $request->validate([
'user_id' => 'required|integer|exists:users,id',
]);
$existing = ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id'])
->first();
if ($existing) {
if ($existing->left_at) {
$existing->update(['left_at' => null, 'joined_at' => now()]);
}
} else {
ConversationParticipant::create([
'conversation_id' => $id,
'user_id' => $data['user_id'],
'role' => 'member',
'joined_at' => now(),
]);
}
$participantUserIds[] = (int) $data['user_id'];
$this->touchConversationCachesForUsers($participantUserIds);
return response()->json(['ok' => true]);
}
// ── DELETE /api/messages/{conversation_id}/remove-user ───────────────────
public function removeUser(Request $request, int $id): JsonResponse
{
$this->requireAdmin($request, $id);
$data = $request->validate([
'user_id' => 'required|integer',
]);
// Cannot remove the conversation creator
$conv = Conversation::findOrFail($id);
abort_if($conv->created_by === (int) $data['user_id'], 403, 'Cannot remove the conversation creator.');
$targetParticipant = ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id'])
->whereNull('left_at')
->first();
if ($targetParticipant && $targetParticipant->role === 'admin') {
$adminCount = ConversationParticipant::where('conversation_id', $id)
->where('role', 'admin')
->whereNull('left_at')
->count();
abort_if($adminCount <= 1, 422, 'Cannot remove the last admin from this conversation.');
}
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id'])
->whereNull('left_at')
->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/rename ──────────────────────────
public function rename(Request $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
$this->requireAdmin($request, $id);
$data = $request->validate(['title' => 'required|string|max:120']);
$conv->update(['title' => $data['title']]);
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
return response()->json(['title' => $conv->title]);
}
// ── Private helpers ──────────────────────────────────────────────────────
private function createDirect(Request $request, User $user, array $data): JsonResponse
{
$recipient = User::findOrFail($data['recipient_id']);
abort_if($recipient->id === $user->id, 422, 'You cannot message yourself.');
if (! $recipient->allowsMessagesFrom($user)) {
abort(403, 'This user does not accept messages from you.');
}
$this->assertNotBlockedBetween($user, $recipient);
// Reuse existing conversation if one exists
$conv = Conversation::findDirect($user->id, $recipient->id);
if (! $conv) {
$conv = DB::transaction(function () use ($user, $recipient) {
$conv = Conversation::create([
'type' => 'direct',
'created_by' => $user->id,
]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()],
]);
return $conv;
});
}
// Insert first / next message
$message = $conv->messages()->create([
'sender_id' => $user->id,
'body' => $data['body'],
]);
$conv->update(['last_message_at' => $message->created_at]);
app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user);
$this->touchConversationCachesForUsers([$user->id, $recipient->id]);
return response()->json($conv->load('allParticipants.user:id,username'), 201);
}
private function createGroup(Request $request, User $user, array $data): JsonResponse
{
$participantIds = array_unique(array_merge([$user->id], $data['participant_ids']));
$conv = DB::transaction(function () use ($user, $data, $participantIds) {
$conv = Conversation::create([
'type' => 'group',
'title' => $data['title'],
'created_by' => $user->id,
]);
$rows = array_map(fn ($uid) => [
'conversation_id' => $conv->id,
'user_id' => $uid,
'role' => $uid === $user->id ? 'admin' : 'member',
'joined_at' => now(),
], $participantIds);
ConversationParticipant::insert($rows);
$message = $conv->messages()->create([
'sender_id' => $user->id,
'body' => $data['body'],
]);
$conv->update(['last_message_at' => $message->created_at]);
return [$conv, $message];
});
[$conversation, $message] = $conv;
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user);
$this->touchConversationCachesForUsers($participantIds);
return response()->json($conversation->load('allParticipants.user:id,username'), 201);
}
private function findAuthorized(Request $request, int $id): Conversation
{
$conv = Conversation::findOrFail($id);
$this->assertParticipant($request, $id);
return $conv;
}
private function participantRecord(Request $request, int $conversationId): ConversationParticipant
{
return ConversationParticipant::where('conversation_id', $conversationId)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->firstOrFail();
}
private function assertParticipant(Request $request, int $id): void
{
abort_unless(
ConversationParticipant::where('conversation_id', $id)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->exists(),
403,
'You are not a participant of this conversation.'
);
}
private function requireAdmin(Request $request, int $id): void
{
abort_unless(
ConversationParticipant::where('conversation_id', $id)
->where('user_id', $request->user()->id)
->where('role', 'admin')
->whereNull('left_at')
->exists(),
403,
'Only admins can perform this action.'
);
}
private function touchConversationCachesForUsers(array $userIds): void
{
foreach (array_unique($userIds) as $userId) {
if (! $userId) {
continue;
}
$versionKey = $this->cacheVersionKey((int) $userId);
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
}
private function cacheVersionKey(int $userId): string
{
return "messages:conversations:version:{$userId}";
}
private function conversationListCacheKey(int $userId, int $page, int $version): string
{
return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}";
}
private function assertNotBlockedBetween(User $sender, User $recipient): void
{
if (! Schema::hasTable('user_blocks')) {
return;
}
$blocked = false;
if (Schema::hasColumns('user_blocks', ['user_id', 'blocked_user_id'])) {
$blocked = DB::table('user_blocks')
->where(function ($q) use ($sender, $recipient) {
$q->where('user_id', $sender->id)->where('blocked_user_id', $recipient->id);
})
->orWhere(function ($q) use ($sender, $recipient) {
$q->where('user_id', $recipient->id)->where('blocked_user_id', $sender->id);
})
->exists();
} elseif (Schema::hasColumns('user_blocks', ['blocker_id', 'blocked_id'])) {
$blocked = DB::table('user_blocks')
->where(function ($q) use ($sender, $recipient) {
$q->where('blocker_id', $sender->id)->where('blocked_id', $recipient->id);
})
->orWhere(function ($q) use ($sender, $recipient) {
$q->where('blocker_id', $recipient->id)->where('blocked_id', $sender->id);
})
->exists();
}
abort_if($blocked, 403, 'Messaging is not available between these users.');
}
}

View File

@@ -0,0 +1,351 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Events\MessageSent;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\MessageReaction;
use App\Services\Messaging\MessageSearchIndexer;
use App\Services\Messaging\MessageNotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
class MessageController extends Controller
{
private const PAGE_SIZE = 30;
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
public function index(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$cursor = $request->integer('cursor');
$query = Message::withTrashed()
->where('conversation_id', $conversationId)
->with(['sender:id,username', 'reactions', 'attachments'])
->orderByDesc('created_at')
->orderByDesc('id');
if ($cursor) {
$query->where('id', '<', $cursor);
}
$chunk = $query->limit(self::PAGE_SIZE + 1)->get();
$hasMore = $chunk->count() > self::PAGE_SIZE;
$messages = $chunk->take(self::PAGE_SIZE)->reverse()->values();
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
return response()->json([
'data' => $messages,
'next_cursor' => $nextCursor,
]);
}
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
public function store(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate([
'body' => 'nullable|string|max:5000',
'attachments' => 'sometimes|array|max:5',
'attachments.*' => 'file|max:25600',
]);
$body = trim((string) ($data['body'] ?? ''));
$files = $request->file('attachments', []);
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
$message = Message::create([
'conversation_id' => $conversationId,
'sender_id' => $request->user()->id,
'body' => $body,
]);
foreach ($files as $file) {
if ($file instanceof UploadedFile) {
$this->storeAttachment($file, $message, (int) $request->user()->id);
}
}
Conversation::where('id', $conversationId)
->update(['last_message_at' => $message->created_at]);
$conversation = Conversation::findOrFail($conversationId);
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user());
app(MessageSearchIndexer::class)->indexMessage($message);
event(new MessageSent($conversationId, $message->id, $request->user()->id));
$participantUserIds = ConversationParticipant::where('conversation_id', $conversationId)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
$message->load(['sender:id,username', 'attachments']);
return response()->json($message, 201);
}
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
public function react(Request $request, int $conversationId, int $messageId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
])->first();
if ($existing) {
$existing->delete();
} else {
MessageReaction::create([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
]);
}
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
// ── DELETE /api/messages/{conversation_id}/react ─────────────────────────
public function unreact(Request $request, int $conversationId, int $messageId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']);
MessageReaction::where([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
])->delete();
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
public function reactByMessage(Request $request, int $messageId): JsonResponse
{
$message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
])->first();
if ($existing) {
$existing->delete();
} else {
MessageReaction::create([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
]);
}
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
public function unreactByMessage(Request $request, int $messageId): JsonResponse
{
$message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']);
MessageReaction::where([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
])->delete();
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
// ── PATCH /api/messages/message/{messageId} ───────────────────────────────
public function update(Request $request, int $messageId): JsonResponse
{
$message = Message::findOrFail($messageId);
abort_unless(
$message->sender_id === $request->user()->id,
403,
'You may only edit your own messages.'
);
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
$data = $request->validate(['body' => 'required|string|max:5000']);
$message->update([
'body' => $data['body'],
'edited_at' => now(),
]);
app(MessageSearchIndexer::class)->updateMessage($message);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
return response()->json($message->fresh());
}
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
public function destroy(Request $request, int $messageId): JsonResponse
{
$message = Message::findOrFail($messageId);
abort_unless(
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
403,
'You may only delete your own messages.'
);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
->whereNull('left_at')
->pluck('user_id')
->all();
app(MessageSearchIndexer::class)->deleteMessage($message);
$message->delete();
$this->touchConversationCachesForUsers($participantUserIds);
return response()->json(['ok' => true]);
}
// ── Private helpers ──────────────────────────────────────────────────────
private function assertParticipant(Request $request, int $conversationId): void
{
abort_unless(
ConversationParticipant::where('conversation_id', $conversationId)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->exists(),
403,
'You are not a participant of this conversation.'
);
}
private function touchConversationCachesForUsers(array $userIds): void
{
foreach (array_unique($userIds) as $userId) {
if (! $userId) {
continue;
}
$versionKey = "messages:conversations:version:{$userId}";
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
}
private function assertAllowedReaction(string $reaction): void
{
$allowed = (array) config('messaging.reactions.allowed', []);
abort_unless(in_array($reaction, $allowed, true), 422, 'Reaction is not allowed.');
}
private function reactionSummary(int $messageId, int $userId): array
{
$rows = MessageReaction::query()
->selectRaw('reaction, count(*) as aggregate_count')
->where('message_id', $messageId)
->groupBy('reaction')
->get();
$summary = [];
foreach ($rows as $row) {
$summary[(string) $row->reaction] = (int) $row->aggregate_count;
}
$mine = MessageReaction::query()
->where('message_id', $messageId)
->where('user_id', $userId)
->pluck('reaction')
->values()
->all();
$summary['me'] = $mine;
return $summary;
}
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void
{
$mime = (string) $file->getMimeType();
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname());
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
$allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []);
$allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []);
$type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file';
$allowed = $type === 'image' ? $allowedImage : $allowedFile;
abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.');
$maxBytes = $type === 'image'
? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024)
: ((int) config('messaging.attachments.max_file_kb', 25600) * 1024);
abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.');
$year = now()->format('Y');
$month = now()->format('m');
$ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin');
$path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}";
$diskName = (string) config('messaging.attachments.disk', 'local');
Storage::disk($diskName)->put($path, file_get_contents($file->getPathname()));
$width = null;
$height = null;
if ($type === 'image') {
$dimensions = @getimagesize($file->getPathname());
$width = isset($dimensions[0]) ? (int) $dimensions[0] : null;
$height = isset($dimensions[1]) ? (int) $dimensions[1] : null;
}
MessageAttachment::query()->create([
'message_id' => $message->id,
'user_id' => $userId,
'type' => $type,
'mime' => $detectedMime,
'size_bytes' => (int) $file->getSize(),
'width' => $width,
'height' => $height,
'sha256' => hash_file('sha256', $file->getPathname()),
'original_name' => substr((string) $file->getClientOriginalName(), 0, 255),
'storage_path' => $path,
'created_at' => now(),
]);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Services\Messaging\MessageSearchIndexer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Meilisearch\Client;
class MessageSearchController extends Controller
{
public function __construct(
private readonly MessageSearchIndexer $indexer,
) {}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$data = $request->validate([
'q' => 'required|string|min:1|max:200',
'conversation_id' => 'nullable|integer|exists:conversations,id',
'cursor' => 'nullable|integer|min:0',
]);
$allowedConversationIds = ConversationParticipant::query()
->where('user_id', $user->id)
->whereNull('left_at')
->pluck('conversation_id')
->map(fn ($id) => (int) $id)
->all();
$conversationId = isset($data['conversation_id']) ? (int) $data['conversation_id'] : null;
if ($conversationId !== null && ! in_array($conversationId, $allowedConversationIds, true)) {
abort(403, 'You are not a participant of this conversation.');
}
if (empty($allowedConversationIds)) {
return response()->json(['data' => [], 'next_cursor' => null]);
}
$limit = max(1, (int) config('messaging.search.page_size', 20));
$offset = max(0, (int) ($data['cursor'] ?? 0));
$hits = collect();
$estimated = 0;
try {
$client = new Client(
config('scout.meilisearch.host'),
config('scout.meilisearch.key')
);
$prefix = (string) config('scout.prefix', '');
$indexName = $prefix . (string) config('messaging.search.index', 'messages');
$conversationFilter = $conversationId !== null
? "conversation_id = {$conversationId}"
: 'conversation_id IN [' . implode(',', $allowedConversationIds) . ']';
$result = $client
->index($indexName)
->search((string) $data['q'], [
'limit' => $limit,
'offset' => $offset,
'sort' => ['created_at:desc'],
'filter' => $conversationFilter,
]);
$hits = collect($result->getHits() ?? []);
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count());
} catch (\Throwable) {
$query = Message::query()
->select('id')
->whereNull('deleted_at')
->whereIn('conversation_id', $allowedConversationIds)
->when($conversationId !== null, fn ($q) => $q->where('conversation_id', $conversationId))
->where('body', 'like', '%' . (string) $data['q'] . '%')
->orderByDesc('created_at')
->orderByDesc('id');
$estimated = (clone $query)->count();
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
}
$messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all();
$messages = Message::query()
->whereIn('id', $messageIds)
->whereIn('conversation_id', $allowedConversationIds)
->whereNull('deleted_at')
->with(['sender:id,username', 'attachments'])
->get()
->keyBy('id');
$ordered = $hits
->map(function (array $hit) use ($messages) {
$message = $messages->get((int) ($hit['id'] ?? 0));
if (! $message) {
return null;
}
return [
'id' => $message->id,
'conversation_id' => $message->conversation_id,
'sender_id' => $message->sender_id,
'sender' => $message->sender,
'body' => $message->body,
'created_at' => optional($message->created_at)?->toISOString(),
'has_attachments' => $message->attachments->isNotEmpty(),
];
})
->filter()
->values();
$nextCursor = ($offset + $limit) < $estimated ? ($offset + $limit) : null;
return response()->json([
'data' => $ordered,
'next_cursor' => $nextCursor,
]);
}
public function rebuild(Request $request): JsonResponse
{
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');
$conversationId = $request->integer('conversation_id');
if ($conversationId > 0) {
$this->indexer->rebuildConversation($conversationId);
return response()->json(['queued' => true, 'scope' => 'conversation']);
}
$this->indexer->rebuildAll();
return response()->json(['queued' => true, 'scope' => 'all']);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Manages per-user messaging privacy preference.
*
* GET /api/messages/settings return current setting
* PATCH /api/messages/settings update setting
*/
class MessagingSettingsController extends Controller
{
public function show(Request $request): JsonResponse
{
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
]);
}
public function update(Request $request): JsonResponse
{
$data = $request->validate([
'allow_messages_from' => 'required|in:everyone,followers,mutual_followers,nobody',
]);
$request->user()->update($data);
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from,
]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Events\TypingStarted;
use App\Events\TypingStopped;
use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use Illuminate\Cache\Repository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class TypingController extends Controller
{
public function start(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
if ((bool) config('messaging.realtime', false)) {
event(new TypingStarted($conversationId, (int) $request->user()->id));
}
return response()->json(['ok' => true]);
}
public function stop(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$this->store()->forget($this->key($conversationId, (int) $request->user()->id));
if ((bool) config('messaging.realtime', false)) {
event(new TypingStopped($conversationId, (int) $request->user()->id));
}
return response()->json(['ok' => true]);
}
public function index(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$userId = (int) $request->user()->id;
$participants = ConversationParticipant::query()
->where('conversation_id', $conversationId)
->whereNull('left_at')
->where('user_id', '!=', $userId)
->with('user:id,username')
->get();
$typing = $participants
->filter(fn ($p) => $this->store()->has($this->key($conversationId, (int) $p->user_id)))
->map(fn ($p) => [
'user_id' => (int) $p->user_id,
'username' => (string) ($p->user->username ?? ''),
])
->values();
return response()->json(['typing' => $typing]);
}
private function assertParticipant(Request $request, int $conversationId): void
{
abort_unless(
ConversationParticipant::query()
->where('conversation_id', $conversationId)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->exists(),
403,
'You are not a participant of this conversation.'
);
}
private function key(int $conversationId, int $userId): string
{
return "typing:{$conversationId}:{$userId}";
}
private function store(): Repository
{
$store = (string) config('messaging.typing.cache_store', 'redis');
if ($store === 'redis' && ! class_exists('Redis')) {
return Cache::store();
}
try {
return Cache::store($store);
} catch (\Throwable) {
return Cache::store();
}
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\ReactionType;
use App\Http\Controllers\Controller;
use App\Models\ArtworkComment;
use App\Models\ArtworkReaction;
use App\Models\CommentReaction;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Handles reaction toggling for artworks and comments.
*
* POST /api/artworks/{id}/reactions toggle artwork reaction
* POST /api/comments/{id}/reactions toggle comment reaction
* GET /api/artworks/{id}/reactions list artwork reactions
* GET /api/comments/{id}/reactions list comment reactions
*/
class ReactionController extends Controller
{
// ─────────────────────────────────────────────────────────────────────────
// Artwork reactions
// ─────────────────────────────────────────────────────────────────────────
public function artworkReactions(Request $request, int $artworkId): JsonResponse
{
return $this->listReactions('artwork', $artworkId, $request->user()?->id);
}
public function toggleArtworkReaction(Request $request, int $artworkId): JsonResponse
{
$this->validateExists('artworks', $artworkId);
$slug = $this->validateReactionSlug($request);
return $this->toggle(
model: new ArtworkReaction(),
where: ['artwork_id' => $artworkId, 'user_id' => $request->user()->id, 'reaction' => $slug],
countWhere: ['artwork_id' => $artworkId],
entityId: $artworkId,
entityType: 'artwork',
userId: $request->user()->id,
slug: $slug,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Comment reactions
// ─────────────────────────────────────────────────────────────────────────
public function commentReactions(Request $request, int $commentId): JsonResponse
{
return $this->listReactions('comment', $commentId, $request->user()?->id);
}
public function toggleCommentReaction(Request $request, int $commentId): JsonResponse
{
// Make sure comment exists and belongs to a public artwork
$comment = ArtworkComment::with('artwork')
->where('id', $commentId)
->whereHas('artwork', fn ($q) => $q->public()->published())
->firstOrFail();
$slug = $this->validateReactionSlug($request);
return $this->toggle(
model: new CommentReaction(),
where: ['comment_id' => $commentId, 'user_id' => $request->user()->id, 'reaction' => $slug],
countWhere: ['comment_id' => $commentId],
entityId: $commentId,
entityType: 'comment',
userId: $request->user()->id,
slug: $slug,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Shared internals
// ─────────────────────────────────────────────────────────────────────────
private function toggle(
\Illuminate\Database\Eloquent\Model $model,
array $where,
array $countWhere,
int $entityId,
string $entityType,
int $userId,
string $slug,
): JsonResponse {
$table = $model->getTable();
$existing = DB::table($table)->where($where)->first();
if ($existing) {
// Toggle off
DB::table($table)->where($where)->delete();
$active = false;
} else {
// Toggle on
DB::table($table)->insertOrIgnore(array_merge($where, [
'created_at' => now(),
]));
$active = true;
}
// Return fresh totals per reaction type
$totals = $this->getTotals($table, $countWhere, $userId);
return response()->json([
'entity_type' => $entityType,
'entity_id' => $entityId,
'reaction' => $slug,
'active' => $active,
'totals' => $totals,
]);
}
private function listReactions(string $entityType, int $entityId, ?int $userId): JsonResponse
{
if ($entityType === 'artwork') {
$table = 'artwork_reactions';
$where = ['artwork_id' => $entityId];
} else {
$table = 'comment_reactions';
$where = ['comment_id' => $entityId];
}
$totals = $this->getTotals($table, $where, $userId);
return response()->json([
'entity_type' => $entityType,
'entity_id' => $entityId,
'totals' => $totals,
]);
}
/**
* Return per-slug totals and whether the current user has each reaction.
*/
private function getTotals(string $table, array $where, ?int $userId): array
{
$rows = DB::table($table)
->where($where)
->selectRaw('reaction, COUNT(*) as total')
->groupBy('reaction')
->get()
->keyBy('reaction');
$totals = [];
foreach (ReactionType::cases() as $type) {
$slug = $type->value;
$count = (int) ($rows[$slug]->total ?? 0);
// Check if current user has this reaction
$mine = false;
if ($userId && $count > 0) {
$mine = DB::table($table)
->where($where)
->where('reaction', $slug)
->where('user_id', $userId)
->exists();
}
$totals[$slug] = [
'emoji' => $type->emoji(),
'label' => $type->label(),
'count' => $count,
'mine' => $mine,
];
}
return $totals;
}
private function validateReactionSlug(Request $request): string
{
$request->validate([
'reaction' => ['required', 'string', 'in:' . implode(',', ReactionType::values())],
]);
return $request->input('reaction');
}
private function validateExists(string $table, int $id): void
{
if (! DB::table($table)->where('id', $id)->exists()) {
throw new ModelNotFoundException("No [{$table}] record found with id [{$id}].");
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\Report;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReportController extends Controller
{
public function store(Request $request): JsonResponse
{
$user = $request->user();
$data = $request->validate([
'target_type' => 'required|in:message,conversation,user',
'target_id' => 'required|integer|min:1',
'reason' => 'required|string|max:120',
'details' => 'nullable|string|max:4000',
]);
$targetType = $data['target_type'];
$targetId = (int) $data['target_id'];
if ($targetType === 'message') {
$message = Message::query()->findOrFail($targetId);
$allowed = ConversationParticipant::query()
->where('conversation_id', $message->conversation_id)
->where('user_id', $user->id)
->whereNull('left_at')
->exists();
abort_unless($allowed, 403, 'You are not allowed to report this message.');
}
if ($targetType === 'conversation') {
$allowed = ConversationParticipant::query()
->where('conversation_id', $targetId)
->where('user_id', $user->id)
->whereNull('left_at')
->exists();
abort_unless($allowed, 403, 'You are not allowed to report this conversation.');
}
if ($targetType === 'user') {
User::query()->findOrFail($targetId);
}
$report = Report::query()->create([
'reporter_id' => $user->id,
'target_type' => $targetType,
'target_id' => $targetId,
'reason' => $data['reason'],
'details' => $data['details'] ?? null,
'status' => 'open',
]);
return response()->json(['id' => $report->id, 'status' => $report->status], 201);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\Search;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UserSearchController extends Controller
{
/**
* GET /api/search/users?q=gregor&per_page=4
*
* Public, rate-limited. Strips a leading @ from the query so that
* typing "@gregor" and "gregor" both work.
*/
public function __invoke(Request $request): JsonResponse
{
$raw = trim((string) $request->query('q', ''));
$q = ltrim($raw, '@');
if (strlen($q) < 2) {
return response()->json(['data' => []]);
}
$perPage = min((int) $request->query('per_page', 4), 8);
$users = User::query()
->where('is_active', 1)
->whereNull('deleted_at')
->where(function ($qb) use ($q) {
$qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%']);
})
->with(['profile', 'statistics'])
->orderByRaw('LOWER(username) = ? DESC', [strtolower($q)]) // exact match first
->orderBy('username')
->limit($perPage)
->get(['id', 'username']);
$data = $users->map(function (User $user) {
$username = strtolower((string) ($user->username ?? ''));
$avatarHash = $user->profile?->avatar_hash;
$uploadsCount = (int) ($user->statistics?->uploads_count ?? 0);
return [
'id' => $user->id,
'type' => 'user',
'username' => $username,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $avatarHash, 64),
'uploads_count' => $uploadsCount,
'profile_url' => '/@' . $username,
];
});
return response()->json(['data' => $data]);
}
}

View File

@@ -5,51 +5,75 @@ namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ArtworkComment;
use Illuminate\Support\Facades\DB;
use App\Support\AvatarUrl;
use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Carbon\Carbon;
class LatestCommentsController extends Controller
{
private const PER_PAGE = 20;
public function index(Request $request)
{
$hits = 20;
$page_title = 'Latest Comments';
$query = ArtworkComment::with(['user', 'artwork'])
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->orderByDesc('created_at');
// Build initial (first-page, type=all) data for React SSR props
$initialData = Cache::remember('comments.latest.all.page1', 120, function () {
return ArtworkComment::with(['user', 'user.profile', 'artwork'])
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->orderByDesc('artwork_comments.created_at')
->paginate(self::PER_PAGE);
});
$comments = $query->paginate($hits)->withQueryString();
$comments->getCollection()->transform(function (ArtworkComment $c) {
$art = $c->artwork;
$items = $initialData->getCollection()->map(function (ArtworkComment $c) {
$art = $c->artwork;
$user = $c->user;
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg';
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? ($present['url'] ?? null) : null;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
return (object) [
'comment_id' => $c->getKey(),
'comment_description' => $c->content,
'commenter_id' => $c->user_id,
'commenter_username' => $user?->username ?? null,
'country' => $user->country ?? null,
'icon' => $user ? DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash') : null,
'uname' => $user->username ?? $user->name ?? 'User',
'signature' => $user->signature ?? null,
'user_type' => $user->role ?? null,
'id' => $art->id ?? null,
'name' => $art->title ?? null,
'picture' => $art->file_name ?? null,
'thumb' => $thumb,
'artwork_slug' => $art->slug ?? Str::slug($art->title ?? ''),
'datetime' => $c->created_at?->toDateTimeString() ?? now()->toDateTimeString(),
return [
'comment_id' => $c->getKey(),
'comment_text' => e(strip_tags($c->content ?? '')),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'commenter' => [
'id' => $userId,
'username' => $user?->username ?? null,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
],
'artwork' => $art ? [
'id' => $art->id,
'title' => $art->title,
'slug' => $art->slug ?? Str::slug($art->title ?? ''),
'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')),
'thumb' => $thumb,
] : null,
];
});
$page_title = 'Latest Comments';
$props = [
'initialComments' => $items->values()->all(),
'initialMeta' => [
'current_page' => $initialData->currentPage(),
'last_page' => $initialData->lastPage(),
'per_page' => $initialData->perPage(),
'total' => $initialData->total(),
'has_more' => $initialData->hasMorePages(),
],
'isAuthenticated' => (bool) auth()->user(),
];
return view('web.comments.latest', compact('page_title', 'comments'));
return view('web.comments.latest', compact('page_title', 'props'));
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardAwardsController extends Controller
{
public function index(Request $request): View
{
$user = $request->user();
$artworks = Artwork::query()
->where('user_id', (int) $user->id)
->whereHas('awards')
->with(['awardStat', 'stats', 'categories.contentType'])
->orderByDesc(
\App\Models\ArtworkAwardStat::select('score_total')
->whereColumn('artwork_id', 'artworks.id')
->limit(1)
)
->paginate(24)
->withQueryString();
return view('dashboard.awards', [
'artworks' => $artworks,
'page_title' => 'My Awards SkinBase',
]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\UserStatsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
@@ -17,23 +18,10 @@ class FavoriteController extends Controller
$user = $request->user();
$perPage = 20;
$favTable = DB::getSchemaBuilder()->hasTable('user_favorites') ? 'user_favorites' : (DB::getSchemaBuilder()->hasTable('favourites') ? 'favourites' : null);
if (! $favTable) {
return view('dashboard.favorites', ['artworks' => new LengthAwarePaginator([], 0, $perPage)]);
}
$favTable = 'artwork_favourites';
$sort = $request->query('sort', 'newest');
$order = $sort === 'oldest' ? 'asc' : 'desc';
// Determine a column to order by (legacy 'datum' or modern timestamps)
$schema = DB::getSchemaBuilder();
$orderColumn = null;
foreach (['datum', 'created_at', 'created', 'date'] as $col) {
if ($schema->hasColumn($favTable, $col)) {
$orderColumn = $col;
break;
}
}
$orderColumn = 'created_at';
$query = DB::table($favTable)->where('user_id', (int) $user->id);
if ($orderColumn) {
@@ -78,19 +66,18 @@ class FavoriteController extends Controller
$artwork = (int) $last;
}
}
$favTable = DB::getSchemaBuilder()->hasTable('user_favorites') ? 'user_favorites' : (DB::getSchemaBuilder()->hasTable('favourites') ? 'favourites' : null);
if ($favTable) {
$artworkId = is_object($artwork) ? (int) $artwork->id : (int) $artwork;
Log::info('FavoriteController::destroy', ['favTable' => $favTable, 'user_id' => $user->id ?? null, 'artwork' => $artwork, 'artworkId' => $artworkId]);
$deleted = DB::table($favTable)
->where('user_id', (int) $user->id)
->where('artwork_id', $artworkId)
->delete();
$artworkId = is_object($artwork) ? (int) $artwork->id : (int) $artwork;
Log::info('FavoriteController::destroy', ['user_id' => $user->id ?? null, 'artworkId' => $artworkId]);
// Look up creator before deleting so we can decrement their counter
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
// Fallback: some schemas or test setups may not match user_id; try deleting by artwork_id alone
if (! $deleted) {
DB::table($favTable)->where('artwork_id', $artworkId)->delete();
}
DB::table('artwork_favourites')
->where('user_id', (int) $user->id)
->where('artwork_id', $artworkId)
->delete();
if ($creatorId) {
app(UserStatsService::class)->decrementFavoritesReceived($creatorId);
}
return redirect()->route('dashboard.favorites')->with('status', 'favourite-removed');

View File

@@ -3,16 +3,46 @@
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Support\AvatarUrl;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class FollowerController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
// Minimal placeholder: real implementation should query followers table
$followers = [];
$user = $request->user();
$perPage = 30;
return view('dashboard.followers', ['followers' => $followers]);
// People who follow $user (user_id = $user being followed)
$followers = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->where('uf.user_id', $user->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'us.uploads_count',
'uf.created_at as followed_at',
])
->paginate($perPage)
->withQueryString()
->through(fn ($row) => (object) [
'id' => $row->id,
'username' => $row->username,
'uname' => $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0,
'followed_at' => $row->followed_at,
]);
return view('dashboard.followers', [
'followers' => $followers,
'page_title' => 'My Followers',
]);
}
}

View File

@@ -3,16 +3,48 @@
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Support\AvatarUrl;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class FollowingController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
// Minimal placeholder: real implementation should query following relationships
$following = [];
$user = $request->user();
$perPage = 30;
return view('dashboard.following', ['following' => $following]);
// People that $user follows (follower_id = $user)
$following = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->where('uf.follower_id', $user->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'us.uploads_count',
'us.followers_count',
'uf.created_at as followed_at',
])
->paginate($perPage)
->withQueryString()
->through(fn ($row) => (object) [
'id' => $row->id,
'username' => $row->username,
'uname' => $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0,
'followers_count'=> $row->followers_count ?? 0,
'followed_at' => $row->followed_at,
]);
return view('dashboard.following', [
'following' => $following,
'page_title' => 'People I Follow',
]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Messaging;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MessagesPageController extends Controller
{
public function index(Request $request): View
{
return view('messages');
}
public function show(Request $request, int $id): View
{
return view('messages', [
'activeConversationId' => $id,
]);
}
}

View File

@@ -3,17 +3,17 @@
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Services\UserStatsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use App\Models\UserFavorite;
use App\Models\ArtworkFavourite;
class FavouritesController extends Controller
{
public function index(Request $request, $userId = null, $username = null)
{
$userId = $userId ? (int)$userId : ($request->user()->id ?? null);
$userId = $userId ? (int) $userId : ($request->user()->id ?? null);
$page = max(1, (int) $request->query('page', 1));
$hits = 20;
@@ -23,99 +23,39 @@ class FavouritesController extends Controller
$results = collect();
try {
$schema = DB::getSchemaBuilder();
$query = ArtworkFavourite::with(['artwork.user'])
->where('user_id', $userId)
->orderByDesc('created_at')
->orderByDesc('artwork_id');
$total = (int) $query->count();
$favorites = $query->skip($start)->take($hits)->get();
$results = $favorites->map(function ($fav) {
$art = $fav->artwork;
if (! $art) {
return null;
}
$item = (object) $art->toArray();
$item->uname = $art->user?->username ?? $art->user?->name ?? null;
$item->datum = $fav->created_at;
return $item;
})->filter();
} catch (\Throwable $e) {
$schema = null;
}
$userIdCol = Schema::hasColumn('users', 'user_id') ? 'user_id' : 'id';
$userNameCol = null;
foreach (['uname', 'username', 'name'] as $col) {
if (Schema::hasColumn('users', $col)) {
$userNameCol = $col;
break;
}
}
if ($schema && $schema->hasTable('user_favorites') && class_exists(UserFavorite::class)) {
try {
$query = UserFavorite::with(['artwork.user'])
->where('user_id', $userId)
->orderByDesc('created_at')
->orderByDesc('artwork_id');
$total = (int) $query->count();
$favorites = $query->skip($start)->take($hits)->get();
$results = $favorites->map(function ($fav) use ($userNameCol) {
$art = $fav->artwork;
if (! $art) {
return null;
}
$item = (object) $art->toArray();
$item->uname = ($userNameCol && isset($art->user)) ? ($art->user->{$userNameCol} ?? null) : null;
$item->datum = $fav->created_at;
return $item;
})->filter();
} catch (\Throwable $e) {
$total = 0;
$results = collect();
}
} else {
try {
if ($schema && $schema->hasTable('artworks_favourites')) {
$favTable = 'artworks_favourites';
} elseif ($schema && $schema->hasTable('favourites')) {
$favTable = 'favourites';
} else {
$favTable = null;
}
if ($schema && $schema->hasTable('artworks')) {
$artTable = 'artworks';
} elseif ($schema && $schema->hasTable('wallz')) {
$artTable = 'wallz';
} else {
$artTable = null;
}
} catch (\Throwable $e) {
$favTable = null;
$artTable = null;
}
if ($favTable && $artTable) {
try {
$total = (int) DB::table($favTable)->where('user_id', $userId)->count();
$t2JoinCol = 't2.' . $userIdCol;
$t2NameSelect = $userNameCol ? DB::raw("t2.{$userNameCol} as uname") : DB::raw("'' as uname");
$results = DB::table($favTable . ' as t1')
->rightJoin($artTable . ' as t3', 't1.artwork_id', '=', 't3.id')
->leftJoin('users as t2', 't3.user_id', '=', $t2JoinCol)
->where('t1.user_id', $userId)
->select('t3.*', $t2NameSelect, 't1.datum')
->orderByDesc('t1.datum')
->orderByDesc('t1.artwork_id')
->skip($start)
->take($hits)
->get();
} catch (\Throwable $e) {
$total = 0;
$results = collect();
}
}
$total = 0;
$results = collect();
}
$results = collect($results)->filter()->values()->transform(function ($row) {
$row->name = $row->name ?? '';
$row->name = $row->name ?? $row->title ?? '';
$row->slug = $row->slug ?? Str::slug($row->name);
$row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int)$row->id) : null;
$row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int) $row->id) : null;
return $row;
});
$page_title = ($username ?: ($userNameCol ? DB::table('users')->where($userIdCol, $userId)->value($userNameCol) : '')) . ' Favourites';
$displayName = $username ?: (DB::table('users')->where('id', $userId)->value('username') ?? '');
$page_title = $displayName . ' Favourites';
return view('user.favourites', [
'results' => $results,
@@ -134,9 +74,13 @@ class FavouritesController extends Controller
abort(403);
}
$favTable = Schema::hasTable('user_favorites') ? 'user_favorites' : (Schema::hasTable('artworks_favourites') ? 'artworks_favourites' : 'favourites');
$creatorId = (int) DB::table('artworks')->where('id', (int) $artworkId)->value('user_id');
DB::table($favTable)->where('user_id', (int)$userId)->where('artwork_id', (int)$artworkId)->delete();
DB::table('artwork_favourites')->where('user_id', (int) $userId)->where('artwork_id', (int) $artworkId)->delete();
if ($creatorId) {
app(UserStatsService::class)->decrementFavoritesReceived($creatorId);
}
return redirect()->route('legacy.favourites', ['id' => $userId])->with('status', 'Removed from favourites');
}

View File

@@ -8,9 +8,11 @@ use App\Models\Artwork;
use App\Models\ProfileComment;
use App\Models\User;
use App\Services\ArtworkService;
use App\Services\FollowService;
use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService;
use App\Services\UsernameApprovalService;
use App\Services\UserStatsService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
@@ -29,6 +31,8 @@ class ProfileController extends Controller
public function __construct(
private readonly ArtworkService $artworkService,
private readonly UsernameApprovalService $usernameApprovalService,
private readonly FollowService $followService,
private readonly UserStatsService $userStats,
)
{
}
@@ -73,35 +77,15 @@ class ProfileController extends Controller
public function toggleFollow(Request $request, string $username): JsonResponse
{
$normalized = UsernamePolicy::normalize($username);
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
$actorId = (int) Auth::id();
$viewerId = Auth::id();
if ($viewerId === $target->id) {
if ($actorId === $target->id) {
return response()->json(['error' => 'Cannot follow yourself.'], 422);
}
$exists = DB::table('user_followers')
->where('user_id', $target->id)
->where('follower_id', $viewerId)
->exists();
if ($exists) {
DB::table('user_followers')
->where('user_id', $target->id)
->where('follower_id', $viewerId)
->delete();
$following = false;
} else {
DB::table('user_followers')->insertOrIgnore([
'user_id' => $target->id,
'follower_id'=> $viewerId,
'created_at' => now(),
]);
$following = true;
}
$count = DB::table('user_followers')->where('user_id', $target->id)->count();
$following = $this->followService->toggle($actorId, (int) $target->id);
$count = $this->followService->followersCount((int) $target->id);
return response()->json([
'following' => $following,
@@ -510,11 +494,7 @@ class ProfileController extends Controller
// ── Increment profile views (async-safe, ignore errors) ──────────────
if (! $isOwner) {
try {
DB::table('user_statistics')
->updateOrInsert(
['user_id' => $user->id],
['profile_views' => DB::raw('COALESCE(profile_views, 0) + 1'), 'updated_at' => now()]
);
$this->userStats->incrementProfileViews($user->id);
} catch (\Throwable) {}
}

View File

@@ -3,51 +3,87 @@
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class TodayInHistoryController extends Controller
{
public function index(Request $request)
{
$hits = 39;
$perPage = 36;
$artworks = null;
$today = now();
try {
$base = DB::table('featured_works as t0')
->leftJoin('artworks as t1', 't0.artwork_id', '=', 't1.id')
->join('categories as t2', 't1.category', '=', 't2.id')
->where('t1.approved', 1)
->whereRaw('MONTH(t0.post_date) = MONTH(CURRENT_DATE())')
->whereRaw('DAY(t0.post_date) = DAY(CURRENT_DATE())')
->select('t1.id', 't1.name', 't1.picture', 't1.uname', 't1.category', DB::raw('t2.name as category_name'));
// ── Strategy 1: legacy featured_works table (historical data from old site) ─
$hasFeaturedWorks = false;
try { $hasFeaturedWorks = Schema::hasTable('featured_works'); } catch (\Throwable) {}
$artworks = $base->orderBy('t0.post_date','desc')->paginate($hits);
} catch (\Throwable $e) {
$artworks = null;
if ($hasFeaturedWorks) {
try {
$artworks = DB::table('featured_works as f')
->join('artworks as a', 'f.artwork_id', '=', 'a.id')
->where('a.is_approved', true)
->where('a.is_public', true)
->whereNull('a.deleted_at')
->whereRaw('MONTH(f.post_date) = ?', [$today->month])
->whereRaw('DAY(f.post_date) = ?', [$today->day])
->select('a.id', 'a.title as name', 'a.slug', 'a.hash', 'a.thumb_ext',
DB::raw('f.post_date as featured_date'))
->orderBy('f.post_date', 'desc')
->paginate($perPage);
} catch (\Throwable $e) {
$artworks = null;
}
}
if ($artworks && method_exists($artworks, 'getCollection')) {
$artworks->getCollection()->transform(function ($row) {
$row->ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$row->encoded = \App\Services\LegacyService::encode($row->id);
try {
$art = \App\Models\Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$row->thumb_url = $present['url'];
$row->thumb_srcset = $present['srcset'];
} catch (\Throwable $e) {
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
$row->thumb_url = $present['url'];
$row->thumb_srcset = $present['srcset'];
// ── Strategy 2: new artwork_features table ───────────────────────────────
if (!$artworks || $artworks->total() === 0) {
try {
$artworks = DB::table('artwork_features as f')
->join('artworks as a', 'f.artwork_id', '=', 'a.id')
->where('f.is_active', true)
->where('a.is_approved', true)
->where('a.is_public', true)
->whereNull('a.deleted_at')
->whereNotNull('a.published_at')
->whereRaw('MONTH(f.featured_at) = ?', [$today->month])
->whereRaw('DAY(f.featured_at) = ?', [$today->day])
->select('a.id', 'a.title as name', 'a.slug', 'a.hash', 'a.thumb_ext',
DB::raw('f.featured_at as featured_date'))
->orderBy('f.featured_at', 'desc')
->paginate($perPage);
} catch (\Throwable $e) {
$artworks = null;
}
}
// ── Enrich with CDN thumbnails (batch load to avoid N+1) ─────────────────
if ($artworks && method_exists($artworks, 'getCollection') && $artworks->count() > 0) {
$ids = $artworks->getCollection()->pluck('id')->all();
$modelsById = Artwork::whereIn('id', $ids)->get()->keyBy('id');
$artworks->getCollection()->transform(function ($row) use ($modelsById) {
/** @var ?Artwork $art */
$art = $modelsById->get($row->id);
if ($art) {
$row->thumb_url = $art->thumbUrl('md') ?? '/gfx/sb_join.jpg';
$row->art_url = '/art/' . $art->id . '/' . $art->slug;
$row->name = $art->title ?: ($row->name ?? 'Untitled');
} else {
$row->thumb_url = '/gfx/sb_join.jpg';
$row->art_url = '/art/' . $row->id;
$row->name = $row->name ?? 'Untitled';
}
$row->gid_num = ((int)($row->category ?? 0) % 5) * 5;
return $row;
});
}
return view('legacy::today-in-history', [
'artworks' => $artworks,
'artworks' => $artworks,
'page_title' => 'Popular on this day in history',
'todayLabel' => $today->format('F j'),
]);
}
}

View File

@@ -15,11 +15,11 @@ class TopFavouritesController extends Controller
$hits = 21;
$page = max(1, (int) $request->query('page', 1));
$base = DB::table('artworks_favourites as t1')
->rightJoin('wallz as t2', 't1.artwork_id', '=', 't2.id')
->where('t2.approved', 1)
->select('t2.id', 't2.name', 't2.picture', 't2.category', DB::raw('COUNT(*) as num'))
->groupBy('t1.artwork_id');
$base = DB::table('artwork_favourites as t1')
->join('artworks as t2', 't1.artwork_id', '=', 't2.id')
->whereNotNull('t2.published_at')
->select('t2.id', 't2.title as name', 't2.slug', DB::raw('NULL as picture'), DB::raw('NULL as category'), DB::raw('COUNT(*) as num'))
->groupBy('t2.id', 't2.title', 't2.slug');
try {
$paginator = (clone $base)->orderBy('num', 'desc')->paginate($hits)->withQueryString();

View File

@@ -0,0 +1,243 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* DiscoverController
*
* Powers the /discover/* discovery pages:
* - /discover/trending most viewed in last 7 days
* - /discover/fresh latest uploads (replaces /uploads/latest)
* - /discover/top-rated highest favourite count
* - /discover/most-downloaded most downloaded all-time
* - /discover/on-this-day published on this calendar day in previous years
*/
final class DiscoverController extends Controller
{
public function __construct(
private readonly ArtworkService $artworkService,
private readonly ArtworkSearchService $searchService,
) {}
// ─── /discover/trending ──────────────────────────────────────────────────
public function trending(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverTrending($perPage);
$artworks = $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Trending Artworks',
'section' => 'trending',
'description' => 'The most-viewed artworks on Skinbase over the past 7 days.',
'icon' => 'fa-fire',
]);
}
// ─── /discover/fresh ─────────────────────────────────────────────────────
public function fresh(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverFresh($perPage);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Fresh Uploads',
'section' => 'fresh',
'description' => 'The latest artworks just uploaded to Skinbase.',
'icon' => 'fa-bolt',
]);
}
// ─── /discover/top-rated ─────────────────────────────────────────────────
public function topRated(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverTopRated($perPage);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Top Rated Artworks',
'section' => 'top-rated',
'description' => 'The most-loved artworks on Skinbase, ranked by community favourites.',
'icon' => 'fa-medal',
]);
}
// ─── /discover/most-downloaded ───────────────────────────────────────────
public function mostDownloaded(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverMostDownloaded($perPage);
$results->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Most Downloaded',
'section' => 'most-downloaded',
'description' => 'All-time most downloaded artworks on Skinbase.',
'icon' => 'fa-download',
]);
}
// ─── /discover/on-this-day ───────────────────────────────────────────────
public function onThisDay(Request $request)
{
$perPage = 24;
$today = now();
$artworks = Artwork::query()
->public()
->published()
->with(['user:id,name', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
->whereRaw('MONTH(published_at) = ?', [$today->month])
->whereRaw('DAY(published_at) = ?', [$today->day])
->whereRaw('YEAR(published_at) < ?', [$today->year])
->orderByDesc('published_at')
->paginate($perPage)
->withQueryString();
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return view('web.discover.index', [
'artworks' => $artworks,
'page_title' => 'On This Day',
'section' => 'on-this-day',
'description' => 'Artworks published on ' . $today->format('F j') . ' in previous years.',
'icon' => 'fa-calendar-day',
]);
}
// ─── /creators/rising ────────────────────────────────────────────────────
public function risingCreators(Request $request)
{
$perPage = 20;
// Creators with artworks published in the last 90 days, ordered by total recent views.
$hasStats = false;
try { $hasStats = Schema::hasTable('artwork_stats'); } catch (\Throwable) {}
if ($hasStats) {
$sub = Artwork::query()
->public()
->published()
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->where('artworks.published_at', '>=', now()->subDays(90))
->selectRaw('artworks.user_id, SUM(artwork_stats.views) as recent_views, MAX(artworks.published_at) as latest_published')
->groupBy('artworks.user_id');
} else {
$sub = Artwork::query()
->public()
->published()
->where('published_at', '>=', now()->subDays(90))
->selectRaw('user_id, COUNT(*) as recent_views, MAX(published_at) as latest_published')
->groupBy('user_id');
}
$creators = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
->mergeBindings($sub->getQuery())
->join('users as u', 'u.id', '=', 't.user_id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.recent_views', 't.latest_published')
->orderByDesc('t.recent_views')
->orderByDesc('t.latest_published')
->paginate($perPage)
->withQueryString();
$creators->getCollection()->transform(function ($row) {
return (object) [
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
'total' => (int) $row->recent_views,
'metric' => 'views',
];
});
return view('web.creators.rising', [
'creators' => $creators,
'page_title' => 'Rising Creators — Skinbase',
]);
}
// ─── /discover/following ─────────────────────────────────────────────────
public function following(Request $request)
{
$user = $request->user();
$perPage = 24;
// Subquery: IDs of users this viewer follows
$followingIds = DB::table('user_followers')
->where('follower_id', $user->id)
->pluck('user_id');
if ($followingIds->isEmpty()) {
$artworks = Artwork::query()->paginate(0);
return view('web.discover.index', [
'artworks' => $artworks,
'page_title' => 'Following Feed',
'section' => 'following',
'description' => 'Follow some creators to see their work here.',
'icon' => 'fa-user-group',
'empty' => true,
]);
}
$artworks = Artwork::query()
->public()
->published()
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->paginate($perPage)
->withQueryString();
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
return view('web.discover.index', [
'artworks' => $artworks,
'page_title' => 'Following Feed',
'section' => 'following',
'description' => 'The latest artworks from creators you follow.',
'icon' => 'fa-user-group',
]);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private function presentArtwork(Artwork $artwork): object
{
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
return (object) [
'id' => $artwork->id,
'name' => $artwork->title,
'category_name' => $primaryCategory->name ?? '',
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user->name ?? 'Skinbase',
'published_at' => $artwork->published_at,
];
}
}

View File

@@ -15,6 +15,21 @@ final class TagController extends Controller
{
public function __construct(private readonly ArtworkSearchService $search) {}
public function index(Request $request): View
{
$tags = \App\Models\Tag::withCount('artworks')
->orderByDesc('artworks_count')
->paginate(80)
->withQueryString();
return view('web.tags.index', [
'tags' => $tags,
'page_title' => 'Browse Tags — Skinbase',
'page_canonical' => route('tags.index'),
'page_robots' => 'index,follow',
]);
}
public function show(Tag $tag, Request $request): View
{
$sort = $request->query('sort', 'popular'); // popular | latest | downloads

View File

@@ -43,12 +43,10 @@ class ArtworkResource extends JsonResource
->exists();
}
if (Schema::hasTable('user_favorites')) {
$isFavorited = DB::table('user_favorites')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
->exists();
}
$isFavorited = DB::table('artwork_favourites')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
->exists();
if (Schema::hasTable('friends_list') && !empty($this->user?->id)) {
$isFollowing = DB::table('friends_list')

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use App\Models\Message;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class DeleteMessageFromIndexJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public readonly int $messageId) {}
public function handle(): void
{
Message::query()->whereKey($this->messageId)->unsearchable();
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Jobs;
use App\Models\Message;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class IndexMessageJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public readonly int $messageId) {}
public function handle(): void
{
$message = Message::with(['sender:id,username', 'attachments'])->find($this->messageId);
if (! $message) {
return;
}
if (! $message->shouldBeSearchable()) {
$message->unsearchable();
return;
}
$message->searchable();
}
}

42
app/Jobs/IndexUserJob.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Queued job: index (or re-index) a single User in Meilisearch.
* Dispatched by UserStatsService whenever stats change.
*/
class IndexUserJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public readonly int $userId) {}
public function handle(): void
{
$user = User::with('statistics')->find($this->userId);
if (! $user) {
return;
}
if (! $user->shouldBeSearchable()) {
$user->unsearchable();
return;
}
$user->searchable();
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\UserStatsService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Recomputes user_statistics for a batch of user IDs.
* Dispatched by RecomputeUserStatsCommand when --queue is set.
*/
class RecomputeUserStatsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 300;
/**
* @param array<int> $userIds
*/
public function __construct(public readonly array $userIds) {}
public function handle(UserStatsService $statsService): void
{
foreach ($this->userIds as $userId) {
$statsService->recomputeUser((int) $userId);
}
}
}

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;

View File

@@ -6,26 +6,32 @@ namespace App\Observers;
use App\Models\ArtworkAward;
use App\Services\ArtworkAwardService;
use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB;
class ArtworkAwardObserver
{
public function __construct(
private readonly ArtworkAwardService $service
private readonly ArtworkAwardService $service,
private readonly UserStatsService $userStats,
) {}
public function created(ArtworkAward $award): void
{
$this->refresh($award);
$this->trackCreatorStats($award, +1);
}
public function updated(ArtworkAward $award): void
{
$this->refresh($award);
// Medal changed count stays the same; no stat change needed.
}
public function deleted(ArtworkAward $award): void
{
$this->refresh($award);
$this->trackCreatorStats($award, -1);
}
private function refresh(ArtworkAward $award): void
@@ -37,4 +43,21 @@ class ArtworkAwardObserver
$this->service->syncToSearch($artwork);
}
}
private function trackCreatorStats(ArtworkAward $award, int $delta): void
{
$creatorId = DB::table('artworks')
->where('id', $award->artwork_id)
->value('user_id');
if (! $creatorId) {
return;
}
if ($delta > 0) {
$this->userStats->incrementAwardsReceived((int) $creatorId);
} else {
$this->userStats->decrementAwardsReceived((int) $creatorId);
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\ArtworkComment;
use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB;
/**
* Updates the artwork creator's comments_received_count and last_active_at
* when a comment is created or (soft-)deleted.
*/
class ArtworkCommentObserver
{
public function __construct(
private readonly UserStatsService $userStats,
) {}
public function created(ArtworkComment $comment): void
{
$creatorId = $this->creatorId($comment->artwork_id);
if ($creatorId) {
$this->userStats->incrementCommentsReceived($creatorId);
}
// The commenter is "active"
$this->userStats->ensureRow($comment->user_id);
$this->userStats->setLastActiveAt($comment->user_id);
}
/** Soft delete. */
public function deleted(ArtworkComment $comment): void
{
$creatorId = $this->creatorId($comment->artwork_id);
if ($creatorId) {
$this->userStats->decrementCommentsReceived($creatorId);
}
}
/** Hard delete after soft delete — already decremented; nothing to do. */
public function forceDeleted(ArtworkComment $comment): void
{
// Only decrement if the comment was NOT already soft-deleted
// (to avoid double-decrement).
if ($comment->deleted_at === null) {
$creatorId = $this->creatorId($comment->artwork_id);
if ($creatorId) {
$this->userStats->decrementCommentsReceived($creatorId);
}
}
}
private function creatorId(int $artworkId): ?int
{
$id = DB::table('artworks')
->where('id', $artworkId)
->value('user_id');
return $id !== null ? (int) $id : null;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\ArtworkFavourite;
use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB;
/**
* Updates the artwork creator's favorites_received_count and last_active_at
* whenever a favourite is added or removed.
*/
class ArtworkFavouriteObserver
{
public function __construct(
private readonly UserStatsService $userStats,
) {}
public function created(ArtworkFavourite $favourite): void
{
$creatorId = $this->creatorId($favourite->artwork_id);
if ($creatorId) {
$this->userStats->incrementFavoritesReceived($creatorId);
}
}
public function deleted(ArtworkFavourite $favourite): void
{
$creatorId = $this->creatorId($favourite->artwork_id);
if ($creatorId) {
$this->userStats->decrementFavoritesReceived($creatorId);
}
}
private function creatorId(int $artworkId): ?int
{
$id = DB::table('artworks')
->where('id', $artworkId)
->value('user_id');
return $id !== null ? (int) $id : null;
}
}

View File

@@ -6,22 +6,27 @@ namespace App\Observers;
use App\Models\Artwork;
use App\Services\ArtworkSearchIndexer;
use App\Services\UserStatsService;
/**
* Syncs artwork documents to Meilisearch on every relevant model event.
* Also keeps user_statistics.uploads_count and last_upload_at in sync.
*
* All operations are dispatched to the queue no blocking calls.
*/
class ArtworkObserver
{
public function __construct(
private readonly ArtworkSearchIndexer $indexer
private readonly ArtworkSearchIndexer $indexer,
private readonly UserStatsService $userStats,
) {}
/** New artwork created — index once published and approved. */
/** New artwork created — index; bump uploadscount + last_upload_at. */
public function created(Artwork $artwork): void
{
$this->indexer->index($artwork);
$this->userStats->incrementUploads($artwork->user_id);
$this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at);
}
/** Artwork updated — covers publish, approval, metadata changes. */
@@ -36,21 +41,29 @@ class ArtworkObserver
$this->indexer->update($artwork);
}
/** Soft delete — remove from search. */
/** Soft delete — remove from search and decrement uploads_count. */
public function deleted(Artwork $artwork): void
{
$this->indexer->delete($artwork->id);
$this->userStats->decrementUploads($artwork->user_id);
}
/** Force delete — ensure removal from index. */
/** Force delete — ensure removal from index; only decrement if NOT already soft-deleted. */
public function forceDeleted(Artwork $artwork): void
{
$this->indexer->delete($artwork->id);
// If deleted_at was null the artwork was not soft-deleted before;
// the deleted() event did NOT fire, so we decrement here.
if ($artwork->deleted_at === null) {
$this->userStats->decrementUploads($artwork->user_id);
}
}
/** Restored from soft-delete — re-index. */
/** Restored from soft-delete — re-index and re-increment uploads_count. */
public function restored(Artwork $artwork): void
{
$this->indexer->index($artwork);
$this->userStats->incrementUploads($artwork->user_id);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\ArtworkReaction;
use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB;
/**
* Updates the artwork creator's reactions_received_count when
* a reaction is added or removed.
*/
class ArtworkReactionObserver
{
public function __construct(
private readonly UserStatsService $userStats,
) {}
public function created(ArtworkReaction $reaction): void
{
$creatorId = $this->creatorId($reaction->artwork_id);
if ($creatorId) {
$this->userStats->incrementReactionsReceived($creatorId);
}
// The reactor is "active"
$this->userStats->ensureRow($reaction->user_id);
$this->userStats->setLastActiveAt($reaction->user_id);
}
public function deleted(ArtworkReaction $reaction): void
{
$creatorId = $this->creatorId($reaction->artwork_id);
if ($creatorId) {
$this->userStats->decrementReactionsReceived($creatorId);
}
}
private function creatorId(int $artworkId): ?int
{
$id = DB::table('artworks')
->where('id', $artworkId)
->value('user_id');
return $id !== null ? (int) $id : null;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Policies;
use App\Models\ArtworkComment;
use App\Models\User;
class ArtworkCommentPolicy
{
/**
* Users can update their own comments.
*/
public function update(User $user, ArtworkComment $comment): bool
{
return $user->id === (int) $comment->user_id;
}
/**
* Users can delete their own comments; admins can delete any comment.
*/
public function delete(User $user, ArtworkComment $comment): bool
{
return $user->id === (int) $comment->user_id || $user->is_admin;
}
}

View File

@@ -6,10 +6,16 @@ use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use App\Models\ArtworkAward;
use App\Observers\ArtworkAwardObserver;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkComment;
use App\Models\ArtworkFavourite;
use App\Models\ArtworkReaction;
use App\Observers\ArtworkAwardObserver;
use App\Observers\ArtworkCommentObserver;
use App\Observers\ArtworkFavouriteObserver;
use App\Observers\ArtworkObserver;
use App\Observers\ArtworkReactionObserver;
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use App\Services\Upload\UploadDraftService;
use Illuminate\Support\Facades\View;
@@ -44,10 +50,14 @@ class AppServiceProvider extends ServiceProvider
$this->configureAuthRateLimiters();
$this->configureUploadRateLimiters();
$this->configureMessagingRateLimiters();
$this->configureMailFailureLogging();
ArtworkAward::observe(ArtworkAwardObserver::class);
Artwork::observe(ArtworkObserver::class);
ArtworkFavourite::observe(ArtworkFavouriteObserver::class);
ArtworkComment::observe(ArtworkCommentObserver::class);
ArtworkReaction::observe(ArtworkReactionObserver::class);
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
@@ -65,18 +75,23 @@ class AppServiceProvider extends ServiceProvider
}
try {
// legacy table name fallback handled elsewhere; here we look for user_favorites or favourites
$favCount = DB::table('user_favorites')->where('user_id', $userId)->count();
$favCount = DB::table('artwork_favourites')->where('user_id', $userId)->count();
} catch (\Throwable $e) {
try {
$favCount = DB::table('favourites')->where('user_id', $userId)->count();
} catch (\Throwable $e) {
$favCount = 0;
}
$favCount = 0;
}
try {
$msgCount = DB::table('messages')->where('reciever_id', $userId)->whereNull('read_at')->count();
$msgCount = (int) DB::table('conversation_participants as cp')
->join('messages as m', 'm.conversation_id', '=', 'cp.conversation_id')
->where('cp.user_id', $userId)
->whereNull('cp.left_at')
->whereNull('m.deleted_at')
->where('m.sender_id', '!=', $userId)
->where(function ($q) {
$q->whereNull('cp.last_read_at')
->orWhereColumn('m.created_at', '>', 'cp.last_read_at');
})
->count();
} catch (\Throwable $e) {
$msgCount = 0;
}
@@ -179,4 +194,25 @@ class AppServiceProvider extends ServiceProvider
return $limits;
}
private function configureMessagingRateLimiters(): void
{
RateLimiter::for('messages-send', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
return [
Limit::perMinute(20)->by('messages:user:' . $userId),
Limit::perMinute(40)->by('messages:ip:' . $request->ip()),
];
});
RateLimiter::for('messages-react', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
return [
Limit::perMinute(60)->by('messages:react:user:' . $userId),
Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()),
];
});
}
}

View File

@@ -5,8 +5,10 @@ use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvid
use Illuminate\Support\Facades\Gate;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkComment;
use App\Policies\ArtworkPolicy;
use App\Policies\ArtworkAwardPolicy;
use App\Policies\ArtworkCommentPolicy;
class AuthServiceProvider extends ServiceProvider
{
@@ -16,8 +18,9 @@ class AuthServiceProvider extends ServiceProvider
* @var array<class-string, class-string>
*/
protected $policies = [
Artwork::class => ArtworkPolicy::class,
ArtworkAward::class => ArtworkAwardPolicy::class,
Artwork::class => ArtworkPolicy::class,
ArtworkAward::class => ArtworkAwardPolicy::class,
ArtworkComment::class => ArtworkCommentPolicy::class,
];
/**

View File

@@ -69,15 +69,21 @@ class ArtworkAwardService
/**
* Remove an award for a user/artwork pair.
* Uses model-level delete so the ArtworkAwardObserver fires.
*/
public function removeAward(Artwork $artwork, User $user): void
{
ArtworkAward::where('artwork_id', $artwork->id)
$award = ArtworkAward::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->delete();
->first();
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
if ($award) {
$award->delete(); // fires ArtworkAwardObserver::deleted
} else {
// Nothing to remove, but still sync stats to be safe.
$this->recalcStats($artwork->id);
$this->syncToSearch($artwork);
}
}
/**

View File

@@ -172,6 +172,73 @@ final class ArtworkSearchService
});
}
// ── Discover section helpers ───────────────────────────────────────────────
/**
* Trending: most viewed artworks, weighted toward recent uploads.
* Uses views:desc + recency via created_at:desc as tiebreaker.
*/
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.trending.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['views:desc', 'created_at:desc'],
])
->paginate($perPage);
});
}
/**
* Fresh: newest uploads first.
*/
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['created_at:desc'],
])
->paginate($perPage);
});
}
/**
* Top rated: highest number of favourites/likes.
*/
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['likes:desc', 'views:desc'],
])
->paginate($perPage);
});
}
/**
* Most downloaded: highest download count.
*/
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['downloads:desc', 'views:desc'],
])
->paginate($perPage);
});
}
// -------------------------------------------------------------------------
private function parseSort(string $sort): array

View File

@@ -1,9 +1,13 @@
<?php
namespace App\Services;
use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Throwable;
/**
* ArtworkStatsService
*
@@ -14,147 +18,200 @@ use Illuminate\Support\Facades\Redis;
*/
class ArtworkStatsService
{
protected string $redisKey = 'artwork_stats:deltas';
protected string $redisKey = 'artwork_stats:deltas';
/**
* Increment views for an artwork.
* Set $defer=true to push to Redis for async processing when available.
*/
/**
* Increment views for an artwork.
* Set $defer=true to push to Redis for async processing when available.
*/
public function incrementViews(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'views', $by);
return;
$this->applyDelta($artworkId, ['views' => $by]);
}
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'views', $by);
return;
}
$this->applyDelta($artworkId, ['views' => $by]);
}
/**
* Increment downloads for an artwork.
*/
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'downloads', $by);
return;
/**
* Increment downloads for an artwork.
*/
public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void
{
if ($defer && $this->redisAvailable()) {
$this->pushDelta($artworkId, 'downloads', $by);
return;
}
$this->applyDelta($artworkId, ['downloads' => $by]);
}
/**
* Increment views using an Artwork model. Preferred API-first signature.
*/
public function incrementViewsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementViews((int) $artwork->id, $by, $defer);
}
}
$this->applyDelta($artworkId, ['downloads' => $by]);
}
/**
* Increment views using an Artwork model.
*/
public function incrementViewsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementViews((int) $artwork->id, $by, $defer);
}
/**
* Apply a set of deltas to the artwork_stats row inside a transaction.
* This method is safe to call from jobs or synchronously.
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
/**
* Increment downloads using an Artwork model.
*/
public function incrementDownloadsForArtwork(\App\Models\Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementDownloads((int) $artwork->id, $by, $defer);
}
/**
* Apply a set of deltas to the artwork_stats row inside a transaction.
* After updating artwork-level stats, forwards view/download counts to
* UserStatsService so creator-level counters stay current.
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
public function applyDelta(int $artworkId, array $deltas): void
{
try {
DB::transaction(function () use ($artworkId, $deltas) {
// Ensure a stats row exists. Insert default zeros if missing.
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artworkId,
try {
DB::transaction(function () use ($artworkId, $deltas) {
// Ensure a stats row exists — insert default zeros if missing.
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artworkId,
'views' => 0,
'downloads' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
/**
* Increment downloads using an Artwork model. Preferred API-first signature.
*/
public function incrementDownloadsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void
{
$this->incrementDownloads((int) $artwork->id, $by, $defer);
}
'views' => 0,
'downloads' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
foreach ($deltas as $column => $value) {
// Only allow known columns to avoid SQL injection.
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
continue;
}
foreach ($deltas as $column => $value) {
// Only allow known columns to avoid SQL injection
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
continue;
}
DB::table('artwork_stats')
->where('artwork_id', $artworkId)
->increment($column, (int) $value);
}
});
DB::table('artwork_stats')
->where('artwork_id', $artworkId)
->increment($column, (int) $value);
}
});
} catch (Throwable $e) {
Log::error('Failed to apply artwork stats delta', ['artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage()]);
}
}
// Forward creator-level counters outside the transaction.
$this->forwardCreatorStats($artworkId, $deltas);
} catch (Throwable $e) {
Log::error('Failed to apply artwork stats delta', [
'artwork_id' => $artworkId,
'deltas' => $deltas,
'error' => $e->getMessage(),
]);
}
}
/**
* Push a delta to Redis queue for async processing.
*/
protected function pushDelta(int $artworkId, string $field, int $value): void
{
$payload = json_encode([
'artwork_id' => $artworkId,
'field' => $field,
'value' => $value,
'ts' => time(),
]);
/**
* After applying artwork-level deltas, forward relevant totals to the
* creator's user_statistics row via UserStatsService.
* Views skip Meilisearch reindex (high frequency covered by recompute).
*
* @param int $artworkId
* @param array<string,int> $deltas
*/
protected function forwardCreatorStats(int $artworkId, array $deltas): void
{
$viewDelta = (int) ($deltas['views'] ?? 0);
$downloadDelta = (int) ($deltas['downloads'] ?? 0);
try {
Redis::rpush($this->redisKey, $payload);
} catch (Throwable $e) {
// If Redis is unavailable, fallback to immediate apply to avoid data loss
Log::warning('Redis unavailable for artwork stats; applying immediately', ['error' => $e->getMessage()]);
$this->applyDelta($artworkId, [$field => $value]);
}
}
if ($viewDelta <= 0 && $downloadDelta <= 0) {
return;
}
/**
* Drain and apply queued deltas from Redis. Returns number processed.
* Designed to be invoked by a queued job or artisan command.
*/
try {
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if (! $creatorId) {
return;
}
/** @var UserStatsService $svc */
$svc = app(UserStatsService::class);
if ($viewDelta > 0) {
// High-frequency: increment counter but skip Meilisearch reindex.
$svc->incrementArtworkViewsReceived($creatorId, $viewDelta);
}
if ($downloadDelta > 0) {
$svc->incrementDownloadsReceived($creatorId, $downloadDelta);
}
} catch (Throwable $e) {
Log::warning('Failed to forward creator stats from artwork delta', [
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
}
}
/**
* Push a delta to Redis queue for async processing.
*/
protected function pushDelta(int $artworkId, string $field, int $value): void
{
$payload = json_encode([
'artwork_id' => $artworkId,
'field' => $field,
'value' => $value,
'ts' => time(),
]);
try {
Redis::rpush($this->redisKey, $payload);
} catch (Throwable $e) {
// If Redis is unavailable, fall back to immediate apply to avoid data loss.
Log::warning('Redis unavailable for artwork stats; applying immediately', [
'error' => $e->getMessage(),
]);
$this->applyDelta($artworkId, [$field => $value]);
}
}
/**
* Drain and apply queued deltas from Redis. Returns number processed.
* Designed to be invoked by a queued job or artisan command.
*/
public function processPendingFromRedis(int $max = 1000): int
{
if (! $this->redisAvailable()) {
return 0;
}
$processed = 0;
if (! $this->redisAvailable()) {
return 0;
}
try {
while ($processed < $max) {
$item = Redis::lpop($this->redisKey);
if (! $item) {
break;
}
$processed = 0;
$decoded = json_decode($item, true);
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
continue;
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
$processed++;
}
} catch (Throwable $e) {
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
}
try {
while ($processed < $max) {
$item = Redis::lpop($this->redisKey);
if (! $item) {
break;
}
return $processed;
}
$decoded = json_decode($item, true);
if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) {
continue;
}
protected function redisAvailable(): bool
{
try {
// Redis facade may throw if not configured
$pong = Redis::connection()->ping();
return (bool) $pong;
} catch (Throwable $e) {
return false;
}
}
$this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]);
$processed++;
}
} catch (Throwable $e) {
Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]);
}
return $processed;
}
protected function redisAvailable(): bool
{
try {
$pong = Redis::connection()->ping();
return (bool) $pong;
} catch (Throwable $e) {
return false;
}
}
}

View File

@@ -0,0 +1,323 @@
<?php
namespace App\Services;
use App\Services\LegacySmileyMapper;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\MarkdownConverter;
/**
* Sanitizes and renders user-submitted content.
*
* Pipeline:
* 1. Strip any raw HTML tags from input (we don't allow HTML)
* 2. Convert legacy <br> / <b> / <i> hints from really old legacy content
* 3. Parse subset of Markdown (bold, italic, code, links, line breaks)
* 4. Sanitize the rendered HTML: whitelist-only tags, strip attributes
* 5. Return safe HTML ready for storage or display
*/
class ContentSanitizer
{
/** Maximum number of emoji allowed before triggering a flood error. */
public const EMOJI_COUNT_MAX = 50;
/**
* Maximum ratio of emoji-to-total-characters before content is considered
* an emoji flood (applies only when emoji count > 5 to avoid false positives
* on very short strings like a single reaction comment).
*/
public const EMOJI_DENSITY_MAX = 0.40;
// HTML tags we allow in the final rendered output
private const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'code', 'pre',
'a', 'ul', 'ol', 'li', 'blockquote', 'del',
];
// Allowed attributes per tag
private const ALLOWED_ATTRS = [
'a' => ['href', 'title', 'rel', 'target'],
];
private static ?MarkdownConverter $converter = null;
// ─────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert raw user input (legacy or new) to sanitized HTML.
*
* @param string|null $raw
* @return string Safe HTML
*/
public static function render(?string $raw): string
{
if ($raw === null || trim($raw) === '') {
return '';
}
// 1. Convert legacy HTML fragments to Markdown-friendly text
$text = static::legacyHtmlToMarkdown($raw);
// 2. Parse Markdown → HTML
$html = static::parseMarkdown($text);
// 3. Sanitize HTML (strip disallowed tags / attrs)
$html = static::sanitizeHtml($html);
return $html;
}
/**
* Strip ALL HTML from input, returning plain text with newlines preserved.
*/
public static function stripToPlain(?string $html): string
{
if ($html === null) {
return '';
}
// Convert <br> and <p> to line breaks before stripping
$text = preg_replace(['/<br\s*\/?>/i', '/<\/p>/i'], "\n", $html);
$text = strip_tags($text ?? '');
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($text);
}
/**
* Validate that a Markdown-lite string does not contain disallowed patterns.
* Returns an array of validation errors (empty = OK).
*/
public static function validate(string $raw): array
{
$errors = [];
if (mb_strlen($raw) > 10_000) {
$errors[] = 'Content exceeds maximum length of 10,000 characters.';
}
// Detect raw HTML tags (we forbid them)
if (preg_match('/<[a-z][^>]*>/i', $raw)) {
$errors[] = 'HTML tags are not allowed. Use Markdown formatting instead.';
}
// Count emoji to prevent absolute spam
$emojiCount = static::countEmoji($raw);
if ($emojiCount > self::EMOJI_COUNT_MAX) {
$errors[] = 'Too many emoji. Please limit emoji usage.';
}
// Reject emoji-flood content: density guard catches e.g. 15 emoji in a
// 20-char string even when the absolute count is below EMOJI_COUNT_MAX.
if ($emojiCount > 5) {
$totalChars = mb_strlen($raw);
if ($totalChars > 0 && ($emojiCount / $totalChars) > self::EMOJI_DENSITY_MAX) {
$errors[] = 'Content is mostly emoji. Please add some text.';
}
}
return $errors;
}
/**
* Collapse consecutive runs of the same emoji in $text.
*
* Delegates to LegacySmileyMapper::collapseFlood() so the behaviour is
* consistent between new submissions and migrated legacy content.
*
* Example: "🍺 🍺 🍺 🍺 🍺 🍺 🍺" (7×) "🍺 🍺 🍺 🍺 🍺 ×7"
*
* @param int $maxRun Keep at most this many consecutive identical emoji.
*/
public static function collapseFlood(string $text, int $maxRun = 5): string
{
return LegacySmileyMapper::collapseFlood($text, $maxRun);
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Convert legacy HTML-style formatting to Markdown equivalents.
* This runs BEFORE Markdown parsing to handle old content gracefully.
*/
private static function legacyHtmlToMarkdown(string $html): string
{
$replacements = [
// Bold
'/<b>(.*?)<\/b>/is' => '**$1**',
'/<strong>(.*?)<\/strong>/is' => '**$1**',
// Italic
'/<i>(.*?)<\/i>/is' => '*$1*',
'/<em>(.*?)<\/em>/is' => '*$1*',
// Line breaks → actual newlines
'/<br\s*\/?>/i' => "\n",
// Paragraphs
'/<p>(.*?)<\/p>/is' => "$1\n\n",
// Strip remaining tags
'/<[^>]+>/' => '',
];
$result = $html;
foreach ($replacements as $pattern => $replacement) {
$result = preg_replace($pattern, $replacement, $result) ?? $result;
}
// Decode HTML entities (e.g. &amp; → &)
$result = html_entity_decode($result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $result;
}
/**
* Parse Markdown-lite subset to HTML.
*/
private static function parseMarkdown(string $text): string
{
$converter = static::getConverter();
$result = $converter->convert($text);
return (string) $result->getContent();
}
/**
* Whitelist-based HTML sanitizer.
* Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes.
*/
private static function sanitizeHtml(string $html): string
{
// Parse with DOMDocument
$doc = new \DOMDocument('1.0', 'UTF-8');
// Suppress warnings from malformed fragments
libxml_use_internal_errors(true);
$doc->loadHTML(
'<html><body>' . $html . '</body></html>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
static::cleanNode($doc->getElementsByTagName('body')->item(0));
// Serialize back, removing the wrapping html/body
$body = $doc->getElementsByTagName('body')->item(0);
$inner = '';
foreach ($body->childNodes as $child) {
$inner .= $doc->saveHTML($child);
}
// Fix self-closing <a></a> etc.
return trim($inner);
}
/**
* Recursively clean a DOMNode strip forbidden tags/attributes.
*/
private static function cleanNode(\DOMNode $node): void
{
$toRemove = [];
$toUnwrap = [];
foreach ($node->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) {
$tag = strtolower($child->nodeName);
if (! in_array($tag, self::ALLOWED_TAGS, true)) {
// Replace element with its text content
$toUnwrap[] = $child;
} else {
// Strip disallowed attributes
$allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? [];
$attrsToRemove = [];
foreach ($child->attributes as $attr) {
if (! in_array($attr->nodeName, $allowedAttrs, true)) {
$attrsToRemove[] = $attr->nodeName;
}
}
foreach ($attrsToRemove as $attrName) {
$child->removeAttribute($attrName);
}
// Force external links to be safe
if ($tag === 'a') {
$href = $child->getAttribute('href');
if ($href && ! static::isSafeUrl($href)) {
$toUnwrap[] = $child;
continue;
}
$child->setAttribute('rel', 'noopener noreferrer nofollow');
$child->setAttribute('target', '_blank');
}
// Recurse
static::cleanNode($child);
}
}
}
// Unwrap forbidden elements (replace with their children)
foreach ($toUnwrap as $el) {
while ($el->firstChild) {
$node->insertBefore($el->firstChild, $el);
}
$node->removeChild($el);
}
}
/**
* Very conservative URL whitelist.
*/
private static function isSafeUrl(string $url): bool
{
$lower = strtolower(trim($url));
// Allow relative paths and anchors
if (str_starts_with($url, '/') || str_starts_with($url, '#')) {
return true;
}
// Only allow http(s)
return str_starts_with($lower, 'http://') || str_starts_with($lower, 'https://');
}
/**
* Count Unicode emoji in a string (basic heuristic).
*/
private static function countEmoji(string $text): int
{
// Match common emoji ranges
preg_match_all(
'/[\x{1F300}-\x{1FAD6}\x{2600}-\x{27BF}\x{FE00}-\x{FEFF}]/u',
$text,
$matches
);
return count($matches[0]);
}
/**
* Lazy-load and cache the Markdown converter.
*/
private static function getConverter(): MarkdownConverter
{
if (static::$converter === null) {
$env = new Environment([
'html_input' => 'strip',
'allow_unsafe_links' => false,
'max_nesting_level' => 10,
]);
$env->addExtension(new CommonMarkCoreExtension());
$env->addExtension(new AutolinkExtension());
$env->addExtension(new StrikethroughExtension());
static::$converter = new MarkdownConverter($env);
}
return static::$converter;
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
/**
* FollowService
*
* Manages follow / unfollow operations on the user_followers table.
* Convention:
* follower_id = the user doing the following
* user_id = the user being followed
*
* Counters in user_statistics are kept in sync atomically inside a transaction.
*/
final class FollowService
{
/**
* Follow $targetId on behalf of $actorId.
*
* @return bool true if a new follow was created, false if already following
*
* @throws \InvalidArgumentException if self-follow attempted
*/
public function follow(int $actorId, int $targetId): bool
{
if ($actorId === $targetId) {
throw new \InvalidArgumentException('Cannot follow yourself.');
}
$inserted = false;
DB::transaction(function () use ($actorId, $targetId, &$inserted) {
$rows = DB::table('user_followers')->insertOrIgnore([
'user_id' => $targetId,
'follower_id' => $actorId,
'created_at' => now(),
]);
if ($rows === 0) {
// Already following nothing to do
return;
}
$inserted = true;
// Increment following_count for actor, followers_count for target
$this->incrementCounter($actorId, 'following_count');
$this->incrementCounter($targetId, 'followers_count');
});
return $inserted;
}
/**
* Unfollow $targetId on behalf of $actorId.
*
* @return bool true if a follow row was removed, false if wasn't following
*/
public function unfollow(int $actorId, int $targetId): bool
{
if ($actorId === $targetId) {
return false;
}
$deleted = false;
DB::transaction(function () use ($actorId, $targetId, &$deleted) {
$rows = DB::table('user_followers')
->where('user_id', $targetId)
->where('follower_id', $actorId)
->delete();
if ($rows === 0) {
return;
}
$deleted = true;
$this->decrementCounter($actorId, 'following_count');
$this->decrementCounter($targetId, 'followers_count');
});
return $deleted;
}
/**
* Toggle follow state. Returns the new following state.
*/
public function toggle(int $actorId, int $targetId): bool
{
if ($this->isFollowing($actorId, $targetId)) {
$this->unfollow($actorId, $targetId);
return false;
}
$this->follow($actorId, $targetId);
return true;
}
public function isFollowing(int $actorId, int $targetId): bool
{
return DB::table('user_followers')
->where('user_id', $targetId)
->where('follower_id', $actorId)
->exists();
}
/**
* Current followers_count for a user (from cached column, not live count).
*/
public function followersCount(int $userId): int
{
return (int) DB::table('user_statistics')
->where('user_id', $userId)
->value('followers_count');
}
// ─── Private helpers ─────────────────────────────────────────────────────
private function incrementCounter(int $userId, string $column): void
{
DB::table('user_statistics')->updateOrInsert(
['user_id' => $userId],
[
$column => DB::raw("COALESCE({$column}, 0) + 1"),
'updated_at' => now(),
'created_at' => now(), // ignored on update
]
);
}
private function decrementCounter(int $userId, string $column): void
{
DB::table('user_statistics')
->where('user_id', $userId)
->where($column, '>', 0)
->update([
$column => DB::raw("{$column} - 1"),
'updated_at' => now(),
]);
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Services;
/**
* Centralized mapping from legacy GIF smiley codes to Unicode emoji.
*
* Usage:
* $result = LegacySmileyMapper::convert($text);
* $map = LegacySmileyMapper::getMap();
*/
class LegacySmileyMapper
{
/**
* The canonical smiley-code emoji map.
* Keys must be unique; variants are listed via aliases.
*/
private static array $map = [
// Core
':beer' => '🍺',
':clap' => '👏',
':coffee' => '☕',
':cry' => '😢',
':lol' => '😂',
':love' => '❤️',
':HB' => '🎂',
':wow' => '😮',
// Extended legacy codes
':smile' => '😊',
':grin' => '😁',
':wink' => '😉',
':tongue' => '😛',
':cool' => '😎',
':angry' => '😠',
':sad' => '😞',
':laugh' => '😆',
':hug' => '🤗',
':thumb' => '👍',
':thumbs' => '👍',
':thumbsup' => '👍',
':fire' => '🔥',
':star' => '⭐',
':heart' => '❤️',
':broken' => '💔',
':music' => '🎵',
':note' => '🎶',
':art' => '🎨',
':camera' => '📷',
':gift' => '🎁',
':cake' => '🎂',
':wave' => '👋',
':ok' => '👌',
':pray' => '🙏',
':think' => '🤔',
':eyes' => '👀',
':rainbow' => '🌈',
':sun' => '☀️',
':moon' => '🌙',
':party' => '🎉',
':bomb' => '💣',
':skull' => '💀',
':alien' => '👽',
':robot' => '🤖',
':poop' => '💩',
':money' => '💰',
':bulb' => '💡',
':check' => '✅',
':x' => '❌',
':warning' => '⚠️',
':question' => '❓',
':exclamation' => '❗',
':100' => '💯',
];
/**
* Convert all legacy smiley codes in $text to Unicode emoji.
* Only replaces codes that are surrounded by whitespace or start/end of string.
*
* @return string
*/
public static function convert(string $text): string
{
if (empty($text)) {
return $text;
}
foreach (static::$map as $code => $emoji) {
// Use word-boundary-style: the code must be followed by whitespace,
// end of string, or punctuation — not part of a word.
$escaped = preg_quote($code, '/');
$text = preg_replace(
'/(?<=\s|^)' . $escaped . '(?=\s|$|[.,!?;])/um',
$emoji,
$text
);
}
return $text;
}
/**
* Returns all codes that are present in the given text (for reporting).
*
* @return string[]
*/
public static function detect(string $text): array
{
$found = [];
foreach (array_keys(static::$map) as $code) {
$escaped = preg_quote($code, '/');
if (preg_match('/(?<=\s|^)' . $escaped . '(?=\s|$|[.,!?;])/um', $text)) {
$found[] = $code;
}
}
return $found;
}
/**
* Collapse consecutive runs of the same emoji that exceed $maxRun repetitions.
*
* Transforms e.g. "🍺 🍺 🍺 🍺 🍺 🍺 🍺 🍺" (8×) "🍺 🍺 🍺 🍺 🍺 ×8"
* so that spam/flood content is stored compactly and rendered readably.
*
* Both whitespace-separated ("🍺 🍺 🍺") and run-together ("🍺🍺🍺") forms
* are collapsed. Only emoji from the common Unicode blocks are affected;
* regular text is never touched.
*
* @param int $maxRun Maximum number of identical emoji to keep (default 5).
*/
public static function collapseFlood(string $text, int $maxRun = 5): string
{
if (empty($text)) {
return $text;
}
$limit = max(1, $maxRun);
// Match one emoji "unit" (codepoint from common ranges + optional variation
// selector U+FE0E / U+FE0F), followed by $limit or more repetitions of
// (optional horizontal whitespace + the same unit).
// The \1 backreference works byte-for-byte in UTF-8, so it correctly
// matches the same multi-byte sequence each time.
$pattern = '/([\x{1F000}-\x{1FFFF}\x{2600}-\x{27EF}][\x{FE0E}\x{FE0F}]?)'
. '([ \t]*\1){' . $limit . ',}/u';
return preg_replace_callback(
$pattern,
static function (array $m) use ($limit): string {
$unit = $m[1];
// substr_count is byte-safe and correct for multi-byte sequences.
$count = substr_count($m[0], $unit);
return str_repeat($unit . ' ', $limit - 1) . $unit . ' ×' . $count;
},
$text
) ?? $text;
}
/**
* Get the full mapping array.
*
* @return array<string, string>
*/
public static function getMap(): array
{
return static::$map;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class MessageNotificationService
{
public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void
{
if (! DB::getSchemaBuilder()->hasTable('notifications')) {
return;
}
$recipientIds = ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->whereNull('left_at')
->where('user_id', '!=', $sender->id)
->where('is_muted', false)
->where('is_archived', false)
->pluck('user_id')
->all();
if (empty($recipientIds)) {
return;
}
$recipientRows = User::query()
->whereIn('id', $recipientIds)
->get()
->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender))
->pluck('id')
->map(fn ($id) => (int) $id)
->values()
->all();
if (empty($recipientRows)) {
return;
}
$preview = Str::limit((string) $message->body, 120, '…');
$now = now();
$rows = array_map(static fn (int $recipientId) => [
'user_id' => $recipientId,
'type' => 'message',
'data' => json_encode([
'conversation_id' => $conversation->id,
'sender_id' => $sender->id,
'sender_name' => $sender->username,
'preview' => $preview,
'message_id' => $message->id,
], JSON_UNESCAPED_UNICODE),
'read_at' => null,
'created_at' => $now,
'updated_at' => $now,
], $recipientRows);
DB::table('notifications')->insert($rows);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Services\Messaging;
use App\Jobs\DeleteMessageFromIndexJob;
use App\Jobs\IndexMessageJob;
use App\Models\Message;
class MessageSearchIndexer
{
public function indexMessage(Message $message): void
{
IndexMessageJob::dispatch($message->id);
}
public function updateMessage(Message $message): void
{
IndexMessageJob::dispatch($message->id);
}
public function deleteMessage(Message $message): void
{
DeleteMessageFromIndexJob::dispatch($message->id);
}
public function rebuildConversation(int $conversationId): void
{
Message::query()
->where('conversation_id', $conversationId)
->whereNull('deleted_at')
->select('id')
->chunkById(200, function ($messages): void {
foreach ($messages as $message) {
IndexMessageJob::dispatch((int) $message->id);
}
});
}
public function rebuildAll(): void
{
Message::query()
->whereNull('deleted_at')
->select('id')
->chunkById(500, function ($messages): void {
foreach ($messages as $message) {
IndexMessageJob::dispatch((int) $message->id);
}
});
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\IndexUserJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* UserStatsService single source of truth for user_statistics counters.
*
* All counter updates MUST go through this service.
* No direct increments in controllers or jobs.
*
* Design:
* - Atomic SQL increments (no read-modify-write races).
* - Negative counters are prevented at the SQL level (WHERE col > 0).
* - ensureRow() upserts the row before any counter touch.
* - recomputeUser() rebuilds all columns from authoritative tables.
*/
final class UserStatsService
{
// ─── Row management ──────────────────────────────────────────────────────
/**
* Guarantee a user_statistics row exists for the given user.
* Safe to call before every increment.
*/
public function ensureRow(int $userId): void
{
DB::table('user_statistics')->insertOrIgnore([
'user_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
]);
}
// ─── Increment helpers ────────────────────────────────────────────────────
public function incrementUploads(int $userId, int $by = 1): void
{
$this->ensureRow($userId);
$this->inc($userId, 'uploads_count', $by);
$this->touchActive($userId);
$this->reindex($userId);
}
public function decrementUploads(int $userId, int $by = 1): void
{
$this->dec($userId, 'uploads_count', $by);
$this->reindex($userId);
}
public function incrementDownloadsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'downloads_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementArtworkViewsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'artwork_views_received_count', $by);
// Views are high-frequency do NOT reindex on every view.
}
public function incrementAwardsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'awards_received_count', $by);
$this->reindex($creatorUserId);
}
public function decrementAwardsReceived(int $creatorUserId, int $by = 1): void
{
$this->dec($creatorUserId, 'awards_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementFavoritesReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'favorites_received_count', $by);
$this->reindex($creatorUserId);
}
public function decrementFavoritesReceived(int $creatorUserId, int $by = 1): void
{
$this->dec($creatorUserId, 'favorites_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementCommentsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'comments_received_count', $by);
$this->reindex($creatorUserId);
}
public function decrementCommentsReceived(int $creatorUserId, int $by = 1): void
{
$this->dec($creatorUserId, 'comments_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementReactionsReceived(int $creatorUserId, int $by = 1): void
{
$this->ensureRow($creatorUserId);
$this->inc($creatorUserId, 'reactions_received_count', $by);
$this->reindex($creatorUserId);
}
public function decrementReactionsReceived(int $creatorUserId, int $by = 1): void
{
$this->dec($creatorUserId, 'reactions_received_count', $by);
$this->reindex($creatorUserId);
}
public function incrementProfileViews(int $userId, int $by = 1): void
{
$this->ensureRow($userId);
$this->inc($userId, 'profile_views_count', $by);
}
// ─── Timestamp helpers ────────────────────────────────────────────────────
public function setLastUploadAt(int $userId, ?Carbon $timestamp = null): void
{
$this->ensureRow($userId);
DB::table('user_statistics')
->where('user_id', $userId)
->update([
'last_upload_at' => ($timestamp ?? now())->toDateTimeString(),
'updated_at' => now(),
]);
}
public function setLastActiveAt(int $userId, ?Carbon $timestamp = null): void
{
$this->ensureRow($userId);
DB::table('user_statistics')
->where('user_id', $userId)
->update([
'last_active_at' => ($timestamp ?? now())->toDateTimeString(),
'updated_at' => now(),
]);
}
// ─── Recompute ────────────────────────────────────────────────────────────
/**
* Recompute all counters for a single user from authoritative tables.
* Returns the computed values (array) without writing when $dryRun=true.
*
* @return array<string, int|string|null>
*/
public function recomputeUser(int $userId, bool $dryRun = false): array
{
$computed = [
'uploads_count' => (int) DB::table('artworks')
->where('user_id', $userId)
->whereNull('deleted_at')
->count(),
'downloads_received_count' => (int) DB::table('artwork_downloads as d')
->join('artworks as a', 'a.id', '=', 'd.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->count(),
'artwork_views_received_count' => (int) DB::table('artwork_stats as s')
->join('artworks as a', 'a.id', '=', 's.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->sum('s.views'),
'awards_received_count' => (int) DB::table('artwork_awards as aw')
->join('artworks as a', 'a.id', '=', 'aw.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->count(),
'favorites_received_count' => (int) DB::table('artwork_favourites as f')
->join('artworks as a', 'a.id', '=', 'f.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->count(),
'comments_received_count' => (int) DB::table('artwork_comments as c')
->join('artworks as a', 'a.id', '=', 'c.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->whereNull('c.deleted_at')
->count(),
'reactions_received_count' => (int) DB::table('artwork_reactions as r')
->join('artworks as a', 'a.id', '=', 'r.artwork_id')
->where('a.user_id', $userId)
->whereNull('a.deleted_at')
->count(),
'followers_count' => (int) DB::table('user_followers')
->where('user_id', $userId)
->count(),
'following_count' => (int) DB::table('user_followers')
->where('follower_id', $userId)
->count(),
'last_upload_at' => DB::table('artworks')
->where('user_id', $userId)
->whereNull('deleted_at')
->max('created_at'),
];
if (! $dryRun) {
$this->ensureRow($userId);
DB::table('user_statistics')
->where('user_id', $userId)
->update(array_merge($computed, ['updated_at' => now()]));
$this->reindex($userId);
}
return $computed;
}
/**
* Recompute stats for all users in chunks.
*
* @param int $chunk Users per chunk.
*/
public function recomputeAll(int $chunk = 1000): void
{
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunk($chunk, function ($users) {
foreach ($users as $user) {
$this->recomputeUser($user->id);
}
});
}
// ─── Private helpers ──────────────────────────────────────────────────────
private function inc(int $userId, string $column, int $by = 1): void
{
DB::table('user_statistics')
->where('user_id', $userId)
->update([
$column => DB::raw("MAX(0, COALESCE({$column}, 0) + {$by})"),
'updated_at' => now(),
]);
}
private function dec(int $userId, string $column, int $by = 1): void
{
DB::table('user_statistics')
->where('user_id', $userId)
->where($column, '>', 0)
->update([
$column => DB::raw("MAX(0, COALESCE({$column}, 0) - {$by})"),
'updated_at' => now(),
]);
}
private function touchActive(int $userId): void
{
DB::table('user_statistics')
->where('user_id', $userId)
->update([
'last_active_at' => now(),
'updated_at' => now(),
]);
}
/**
* Queue a Meilisearch reindex for the user.
* Uses IndexUserJob to avoid blocking the request.
*/
private function reindex(int $userId): void
{
IndexUserJob::dispatch($userId);
}
}

View File

@@ -15,6 +15,7 @@
"laravel/framework": "^12.0",
"laravel/scout": "^10.24",
"laravel/tinker": "^2.10.1",
"league/commonmark": "^2.8",
"meilisearch/meilisearch-php": "^1.16"
},
"require-dev": {

2
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d725824144ac43bf1938e16a5653dcf4",
"content-hash": "dcc955601c6f66f01bb520614508ed66",
"packages": [
{
"name": "brick/math",

28
config/messaging.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
return [
'realtime' => (bool) env('MESSAGING_REALTIME', false),
'typing' => [
'ttl_seconds' => (int) env('MESSAGING_TYPING_TTL', 8),
'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'),
],
'search' => [
'index' => env('MESSAGING_MEILI_INDEX', 'messages'),
'page_size' => (int) env('MESSAGING_SEARCH_PAGE_SIZE', 20),
],
'reactions' => [
'allowed' => ['👍', '❤️', '🔥', '😂', '👏', '😮'],
],
'attachments' => [
'disk' => env('MESSAGING_ATTACHMENTS_DISK', 'local'),
'max_files' => (int) env('MESSAGING_ATTACHMENTS_MAX_FILES', 5),
'max_image_kb' => (int) env('MESSAGING_ATTACHMENTS_MAX_IMAGE_KB', 10240),
'max_file_kb' => (int) env('MESSAGING_ATTACHMENTS_MAX_FILE_KB', 25600),
'allowed_image_mimes' => ['image/jpeg', 'image/png', 'image/webp'],
'allowed_file_mimes' => ['application/pdf', 'application/zip', 'application/x-zip-compressed'],
],
];

View File

@@ -123,6 +123,21 @@ return [
],
],
],
env('SCOUT_PREFIX', env('MEILI_PREFIX', '')) . 'messages' => [
'searchableAttributes' => [
'body_text',
'sender_username',
],
'filterableAttributes' => [
'conversation_id',
'sender_id',
'has_attachments',
],
'sortableAttributes' => [
'created_at',
],
],
],
],

View File

@@ -0,0 +1,32 @@
<?php
namespace Database\Factories;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class ArtworkCommentFactory extends Factory
{
protected $model = ArtworkComment::class;
public function definition(): array
{
$raw = $this->faker->sentence(12);
return [
'artwork_id' => Artwork::factory(),
'user_id' => User::factory(),
'content' => $raw,
'raw_content' => $raw,
'rendered_content' => '<p>' . e($raw) . '</p>',
'is_approved' => true,
];
}
public function unapproved(): static
{
return $this->state(['is_approved' => false]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('artwork_comments', function (Blueprint $table) {
// raw_content stores user-submitted markdown/plain text
// rendered_content stores the sanitized HTML cache
$table->mediumText('raw_content')->nullable()->after('content');
$table->mediumText('rendered_content')->nullable()->after('raw_content');
});
}
public function down(): void
{
Schema::table('artwork_comments', function (Blueprint $table) {
$table->dropColumn(['raw_content', 'rendered_content']);
});
}
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Allowed reaction slugs emoji mapping (authoritative list lives in ReactionType).
* Stored as VARCHAR to avoid MySQL ENUM emoji encoding issues.
*/
public function up(): void
{
Schema::create('artwork_reactions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('artwork_id');
$table->unsignedBigInteger('user_id');
// slug: thumbs_up | heart | fire | laugh | clap | wow
$table->string('reaction', 20);
$table->timestamp('created_at')->useCurrent();
$table->unique(['artwork_id', 'user_id', 'reaction'], 'artwork_reactions_unique');
$table->index('artwork_id');
$table->index('user_id');
$table->foreign('artwork_id')->references('id')->on('artworks')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Schema::create('comment_reactions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('comment_id');
$table->unsignedBigInteger('user_id');
// slug: thumbs_up | heart | fire | laugh | clap | wow
$table->string('reaction', 20);
$table->timestamp('created_at')->useCurrent();
$table->unique(['comment_id', 'user_id', 'reaction'], 'comment_reactions_unique');
$table->index('comment_id');
$table->index('user_id');
$table->foreign('comment_id')->references('id')->on('artwork_comments')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('comment_reactions');
Schema::dropIfExists('artwork_reactions');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Upgrade legacy TEXT columns to MEDIUMTEXT so large imported comments
* (incl. spam-heavy legacy rows) do not cause truncation errors.
*
* TEXT = 65,535 bytes (~64 KB)
* MEDIUMTEXT = 16,777,215 bytes (~16 MB)
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('artwork_comments', function (Blueprint $table) {
$table->mediumText('content')->nullable()->change();
});
Schema::table('forum_posts', function (Blueprint $table) {
$table->mediumText('content')->nullable()->change();
});
}
public function down(): void
{
Schema::table('artwork_comments', function (Blueprint $table) {
$table->text('content')->nullable()->change();
});
Schema::table('forum_posts', function (Blueprint $table) {
$table->text('content')->nullable()->change();
});
}
};

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Modernized replacement for the legacy `favourites` table.
*
* What changed from the legacy schema:
* - Renamed table: favourites artwork_favourites
* - `favourite_id` `id` (bigint unsigned, auto-increment)
* - `datum` `created_at` / `updated_at` via timestamps()
* - `user_type` dropped membership tier is not a property of the
* favourite relationship; query via users.role if needed
* - `author_id` dropped always derivable via artworks.user_id
* - Both FKs are constrained with cascadeOnDelete so orphaned rows are
* automatically cleaned up when an artwork or user is hard-deleted
* - `legacy_id` tracks the original favourite_id for idempotent re-imports
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('artwork_favourites', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete();
$table->foreignId('artwork_id')
->constrained('artworks')
->cascadeOnDelete();
// Preserve original legacy PK for idempotent re-imports.
// NULL for favourites created natively in the new system.
$table->unsignedInteger('legacy_id')->nullable()->unique();
$table->timestamps();
// Prevent duplicate favourites
$table->unique(['user_id', 'artwork_id'], 'artwork_favourites_unique_user_artwork');
// Fast lookup: "how many favourites does this artwork have?"
$table->index('artwork_id');
// Fast lookup: "which artworks has this user favourited?"
$table->index('user_id');
});
}
public function down(): void
{
Schema::dropIfExists('artwork_favourites');
}
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Consolidate onto artwork_favourites.
*
* Any rows in the interim user_favorites table (created 2026-02-07) that are
* not already present in artwork_favourites are copied over, then
* user_favorites is dropped so only one favourites table remains.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('user_favorites')) {
return;
}
// Copy any rows not yet in artwork_favourites, using insertOrIgnore so the
// unique (user_id, artwork_id) constraint silently skips duplicates.
// chunk() avoids memory spikes on large tables.
DB::table('user_favorites')->orderBy('id')->chunk(500, function ($rows) {
DB::table('artwork_favourites')->insertOrIgnore(
$rows->map(fn ($r) => [
'user_id' => $r->user_id,
'artwork_id' => $r->artwork_id,
'created_at' => $r->created_at,
'updated_at' => $r->created_at,
])->all()
);
});
Schema::drop('user_favorites');
}
public function down(): void
{
if (Schema::hasTable('user_favorites')) {
return;
}
Schema::create('user_favorites', function ($table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('artwork_id');
$table->timestamp('created_at')->nullable();
$table->unique(['user_id', 'artwork_id']);
});
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('user_statistics', function (Blueprint $table) {
$table->unsignedInteger('followers_count')->default(0)->after('profile_views');
$table->unsignedInteger('following_count')->default(0)->after('followers_count');
});
// Backfill follow counters using subquery syntax (compatible with MySQL + SQLite).
DB::statement("
UPDATE user_statistics
SET followers_count = (
SELECT COUNT(*) FROM user_followers
WHERE user_followers.user_id = user_statistics.user_id
)
");
DB::statement("
UPDATE user_statistics
SET following_count = (
SELECT COUNT(*) FROM user_followers
WHERE user_followers.follower_id = user_statistics.user_id
)
");
}
public function down(): void
{
Schema::table('user_statistics', function (Blueprint $table) {
$table->dropColumn(['followers_count', 'following_count']);
});
}
};

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* User Statistics v2 in-place schema upgrade.
*
* Renames legacy columns, widens all counters to unsignedBigInteger,
* and adds creator-received metrics + activity timestamps.
*
* Mapping:
* uploads uploads_count
* downloads downloads_received_count
* pageviews artwork_views_received_count
* awards awards_received_count
* profile_views profile_views_count
*
* New columns added:
* favorites_received_count, comments_received_count,
* reactions_received_count, last_upload_at, last_active_at
*
* followers_count / following_count already exist with correct naming only
* widened to bigint.
*/
return new class extends Migration
{
public function up(): void
{
// ── 1. Rename legacy columns ─────────────────────────────────────────
Schema::table('user_statistics', function (Blueprint $table) {
if (Schema::hasColumn('user_statistics', 'uploads')) {
$table->renameColumn('uploads', 'uploads_count');
}
if (Schema::hasColumn('user_statistics', 'downloads')) {
$table->renameColumn('downloads', 'downloads_received_count');
}
if (Schema::hasColumn('user_statistics', 'pageviews')) {
$table->renameColumn('pageviews', 'artwork_views_received_count');
}
if (Schema::hasColumn('user_statistics', 'awards')) {
$table->renameColumn('awards', 'awards_received_count');
}
if (Schema::hasColumn('user_statistics', 'profile_views')) {
$table->renameColumn('profile_views', 'profile_views_count');
}
});
// ── 2. Widen to unsignedBigInteger + add new columns ─────────────────
Schema::table('user_statistics', function (Blueprint $table) {
// Widen existing counters
$table->unsignedBigInteger('uploads_count')->default(0)->change();
$table->unsignedBigInteger('downloads_received_count')->default(0)->change();
$table->unsignedBigInteger('artwork_views_received_count')->default(0)->change();
$table->unsignedBigInteger('awards_received_count')->default(0)->change();
$table->unsignedBigInteger('profile_views_count')->default(0)->change();
$table->unsignedBigInteger('followers_count')->default(0)->change();
$table->unsignedBigInteger('following_count')->default(0)->change();
// Add new creator-received counters
if (! Schema::hasColumn('user_statistics', 'favorites_received_count')) {
$table->unsignedBigInteger('favorites_received_count')->default(0)->after('awards_received_count');
}
if (! Schema::hasColumn('user_statistics', 'comments_received_count')) {
$table->unsignedBigInteger('comments_received_count')->default(0)->after('favorites_received_count');
}
if (! Schema::hasColumn('user_statistics', 'reactions_received_count')) {
$table->unsignedBigInteger('reactions_received_count')->default(0)->after('comments_received_count');
}
// Activity timestamps
if (! Schema::hasColumn('user_statistics', 'last_upload_at')) {
$table->timestamp('last_upload_at')->nullable()->after('reactions_received_count');
}
if (! Schema::hasColumn('user_statistics', 'last_active_at')) {
$table->timestamp('last_active_at')->nullable()->after('last_upload_at');
}
});
// ── 3. Optional: indexes for creator ranking ─────────────────────────
try {
Schema::table('user_statistics', function (Blueprint $table) {
$table->index('awards_received_count', 'idx_us_awards');
});
} catch (\Throwable) {}
try {
Schema::table('user_statistics', function (Blueprint $table) {
$table->index('favorites_received_count', 'idx_us_favorites');
});
} catch (\Throwable) {}
}
public function down(): void
{
// Remove added columns
Schema::table('user_statistics', function (Blueprint $table) {
foreach (['favorites_received_count', 'comments_received_count', 'reactions_received_count', 'last_upload_at', 'last_active_at'] as $col) {
if (Schema::hasColumn('user_statistics', $col)) {
$table->dropColumn($col);
}
}
});
// Drop indexes
Schema::table('user_statistics', function (Blueprint $table) {
try { $table->dropIndex('idx_us_awards'); } catch (\Throwable) {}
try { $table->dropIndex('idx_us_favorites'); } catch (\Throwable) {}
});
// Rename back
Schema::table('user_statistics', function (Blueprint $table) {
if (Schema::hasColumn('user_statistics', 'uploads_count')) {
$table->renameColumn('uploads_count', 'uploads');
}
if (Schema::hasColumn('user_statistics', 'downloads_received_count')) {
$table->renameColumn('downloads_received_count', 'downloads');
}
if (Schema::hasColumn('user_statistics', 'artwork_views_received_count')) {
$table->renameColumn('artwork_views_received_count', 'pageviews');
}
if (Schema::hasColumn('user_statistics', 'awards_received_count')) {
$table->renameColumn('awards_received_count', 'awards');
}
if (Schema::hasColumn('user_statistics', 'profile_views_count')) {
$table->renameColumn('profile_views_count', 'profile_views');
}
});
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('conversations', function (Blueprint $table) {
$table->id();
$table->enum('type', ['direct', 'group'])->default('direct');
$table->string('title')->nullable();
$table->unsignedBigInteger('created_by');
$table->timestamp('last_message_at')->nullable()->index();
$table->timestamps();
$table->foreign('created_by')->references('id')->on('users')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('conversations');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('conversation_participants', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->enum('role', ['member', 'admin'])->default('member');
$table->timestamp('last_read_at')->nullable();
$table->boolean('is_muted')->default(false);
$table->boolean('is_archived')->default(false);
$table->timestamp('joined_at')->useCurrent();
$table->timestamp('left_at')->nullable();
$table->unique(['conversation_id', 'user_id']);
$table->index('user_id');
$table->index('conversation_id');
});
}
public function down(): void
{
Schema::dropIfExists('conversation_participants');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained()->onDelete('cascade');
$table->foreignId('sender_id')->references('id')->on('users')->onDelete('cascade');
$table->mediumText('body');
$table->timestamp('edited_at')->nullable();
$table->softDeletes();
$table->timestamps();
$table->index(['conversation_id', 'created_at']);
$table->index('sender_id');
});
}
public function down(): void
{
Schema::dropIfExists('messages');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('message_reactions', function (Blueprint $table) {
$table->id();
$table->foreignId('message_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('reaction', 32);
$table->timestamp('created_at')->useCurrent();
$table->unique(['message_id', 'user_id', 'reaction']);
$table->index('message_id');
});
}
public function down(): void
{
Schema::dropIfExists('message_reactions');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->enum('allow_messages_from', ['everyone', 'followers', 'mutual_followers', 'nobody'])
->default('everyone')
->after('role');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('allow_messages_from');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('notifications')) {
return;
}
Schema::create('notifications', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('type', 32);
$table->json('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index('user_id');
$table->index('read_at');
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('conversation_participants', function (Blueprint $table): void {
if (! Schema::hasColumn('conversation_participants', 'is_pinned')) {
$table->boolean('is_pinned')->default(false)->after('is_archived');
}
if (! Schema::hasColumn('conversation_participants', 'pinned_at')) {
$table->timestamp('pinned_at')->nullable()->after('is_pinned');
}
$table->index(['user_id', 'is_pinned']);
});
}
public function down(): void
{
Schema::table('conversation_participants', function (Blueprint $table): void {
if (Schema::hasColumn('conversation_participants', 'pinned_at')) {
$table->dropColumn('pinned_at');
}
if (Schema::hasColumn('conversation_participants', 'is_pinned')) {
$table->dropColumn('is_pinned');
}
});
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('message_attachments', function (Blueprint $table): void {
$table->id();
$table->foreignId('message_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('type', ['image', 'file']);
$table->string('mime', 191);
$table->unsignedBigInteger('size_bytes');
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
$table->string('sha256', 64)->nullable();
$table->string('original_name', 255);
$table->string('storage_path', 500);
$table->timestamp('created_at')->useCurrent();
$table->index('message_id');
$table->index('user_id');
});
}
public function down(): void
{
Schema::dropIfExists('message_attachments');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('reports')) {
return;
}
Schema::create('reports', function (Blueprint $table): void {
$table->id();
$table->foreignId('reporter_id')->constrained('users')->cascadeOnDelete();
$table->enum('target_type', ['message', 'conversation', 'user']);
$table->unsignedBigInteger('target_id');
$table->string('reason', 120);
$table->text('details')->nullable();
$table->enum('status', ['open', 'reviewing', 'closed'])->default('open');
$table->timestamps();
$table->index(['target_type', 'target_id']);
$table->index('status');
$table->index('reporter_id');
});
}
public function down(): void
{
Schema::dropIfExists('reports');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('message_reactions', function (Blueprint $table): void {
$table->index('user_id');
});
}
public function down(): void
{
Schema::table('message_reactions', function (Blueprint $table): void {
$table->dropIndex(['user_id']);
});
}
};

1314
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"playwright:install": "npx playwright install"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/react": "^16.1.0",
@@ -27,14 +28,17 @@
"sass": "^1.70.0",
"tailwindcss": "^3.1.0",
"vite": "^7.0.7",
"vitest": "^2.1.8",
"@playwright/test": "^1.40.0"
"vitest": "^2.1.8"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@inertiajs/core": "^1.0.4",
"@inertiajs/react": "^1.0.4",
"emoji-mart": "^5.6.0",
"framer-motion": "^12.34.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,173 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search\u2026
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- link "Upload" [ref=e32] [cursor=pointer]:
- /url: http://skinbase26.test/upload
- img [ref=e33]
- text: Upload
- generic [ref=e35]:
- link "Favourites" [ref=e36] [cursor=pointer]:
- /url: http://skinbase26.test/dashboard/favorites
- img [ref=e37]
- link "Messages" [ref=e39] [cursor=pointer]:
- /url: http://skinbase26.test/messages
- img [ref=e40]
- link "Notifications" [ref=e42] [cursor=pointer]:
- /url: http://skinbase26.test/dashboard/comments
- img [ref=e43]
- button "E2E Owner E2E Owner" [ref=e47] [cursor=pointer]:
- img "E2E Owner" [ref=e48]
- generic [ref=e49]: E2E Owner
- img [ref=e50]
- text:                     
- main [ref=e52]:
- generic [ref=e55]:
- complementary [ref=e56]:
- generic [ref=e57]:
- heading "Messages" [level=1] [ref=e58]
- button "New message" [ref=e59] [cursor=pointer]:
- img [ref=e60]
- searchbox "Search all messages…" [ref=e63]
- generic [ref=e64]:
- searchbox "Search conversations…" [ref=e66]
- list [ref=e67]:
- listitem [ref=e68]:
- button "E e2ep708148630 now Seed latest from owner" [ref=e69] [cursor=pointer]:
- generic [ref=e70]: E
- generic [ref=e71]:
- generic [ref=e72]:
- generic [ref=e74]: e2ep708148630
- generic [ref=e75]: now
- generic [ref=e77]: Seed latest from owner
- main [ref=e78]:
- generic [ref=e79]:
- generic [ref=e80]:
- paragraph [ref=e82]: e2ep708148630
- button "Pin" [ref=e83] [cursor=pointer]
- searchbox "Search in this conversation…" [ref=e85]
- generic [ref=e86]:
- generic [ref=e87]:
- separator [ref=e88]
- generic [ref=e89]: Today
- separator [ref=e90]
- generic [ref=e92]:
- generic [ref=e94]: E
- generic [ref=e95]:
- generic [ref=e96]:
- generic [ref=e97]: e2ep708148630
- generic [ref=e98]: 09:11 PM
- paragraph [ref=e102]: Seed hello
- generic [ref=e104]:
- generic [ref=e106]: E
- generic [ref=e107]:
- generic [ref=e108]:
- generic [ref=e109]: e2eo708148630
- generic [ref=e110]: 09:11 PM
- paragraph [ref=e114]: Seed latest from owner
- generic [ref=e115]: Seen 4s ago
- generic [ref=e116]:
- button "📎" [ref=e117] [cursor=pointer]
- textbox "Write a message… (Enter to send, Shift+Enter for new line)" [ref=e118]
- button "Send" [disabled] [ref=e119]
- contentinfo [ref=e120]:
- generic [ref=e121]:
- generic [ref=e122]:
- img "Skinbase" [ref=e123]
- generic [ref=e124]: Skinbase
- generic [ref=e125]:
- link "Bug Report" [ref=e126] [cursor=pointer]:
- /url: /bug-report
- link "RSS Feeds" [ref=e127] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e128] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e129] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e130] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e131] [cursor=pointer]:
- /url: /privacy-policy
- generic [ref=e132]: © 2026 Skinbase.org
- generic [ref=e133]:
- generic [ref=e135]:
- generic [ref=e137]:
- generic [ref=e138] [cursor=pointer]:
- generic: Request
- generic [ref=e139] [cursor=pointer]:
- generic: Timeline
- generic [ref=e140] [cursor=pointer]:
- generic: Queries
- generic [ref=e141]: "14"
- generic [ref=e142] [cursor=pointer]:
- generic: Models
- generic [ref=e143]: "5"
- generic [ref=e144] [cursor=pointer]:
- generic: Cache
- generic [ref=e145]: "2"
- generic [ref=e146]:
- generic [ref=e153] [cursor=pointer]:
- generic [ref=e154]: "4"
- generic [ref=e155]: GET /api/messages/4
- generic [ref=e156] [cursor=pointer]:
- generic: 706ms
- generic [ref=e158] [cursor=pointer]:
- generic: 28MB
- generic [ref=e160] [cursor=pointer]:
- generic: 12.x
- generic [ref=e162]:
- generic [ref=e164]:
- generic:
- list
- generic [ref=e166]:
- list [ref=e167]
- textbox "Search" [ref=e170]
- generic [ref=e171]:
- list
- generic [ref=e173]:
- list
- list [ref=e178]
- generic [ref=e180]:
- generic:
- list
- generic [ref=e182]:
- list [ref=e183]
- textbox "Search" [ref=e186]
- generic [ref=e187]:
- list
- generic [ref=e189]:
- generic:
- list
```

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More