messages implemented
This commit is contained in:
@@ -223,10 +223,10 @@ class ImportLegacyUsers extends Command
|
||||
DB::table('user_statistics')->updateOrInsert(
|
||||
['user_id' => $legacyId],
|
||||
[
|
||||
'uploads' => $uploads,
|
||||
'downloads' => $downloads,
|
||||
'pageviews' => $pageviews,
|
||||
'awards' => $awards,
|
||||
'uploads_count' => $uploads,
|
||||
'downloads_received_count' => $downloads,
|
||||
'artwork_views_received_count' => $pageviews,
|
||||
'awards_received_count' => $awards,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
|
||||
325
app/Console/Commands/MigrateFavourites.php
Normal file
325
app/Console/Commands/MigrateFavourites.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
351
app/Console/Commands/MigrateFollows.php
Normal file
351
app/Console/Commands/MigrateFollows.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
246
app/Console/Commands/MigrateMessagesCommand.php
Normal file
246
app/Console/Commands/MigrateMessagesCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
143
app/Console/Commands/MigrateSmileys.php
Normal file
143
app/Console/Commands/MigrateSmileys.php
Normal 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];
|
||||
}
|
||||
}
|
||||
147
app/Console/Commands/RecomputeUserStatsCommand.php
Normal file
147
app/Console/Commands/RecomputeUserStatsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
188
app/Console/Commands/SanitizeContent.php
Normal file
188
app/Console/Commands/SanitizeContent.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class Kernel extends ConsoleKernel
|
||||
EvaluateFeedWeightsCommand::class,
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
63
app/Enums/ReactionType.php
Normal file
63
app/Enums/ReactionType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
19
app/Events/MessageSent.php
Normal file
19
app/Events/MessageSent.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
16
app/Events/TypingStarted.php
Normal file
16
app/Events/TypingStarted.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
16
app/Events/TypingStopped.php
Normal file
16
app/Events/TypingStopped.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
178
app/Http/Controllers/Api/ArtworkCommentController.php
Normal file
178
app/Http/Controllers/Api/ArtworkCommentController.php
Normal 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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
'followers_count' => $svc->followersCount($userId),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
137
app/Http/Controllers/Api/FollowController.php
Normal file
137
app/Http/Controllers/Api/FollowController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
113
app/Http/Controllers/Api/LatestCommentsApiController.php
Normal file
113
app/Http/Controllers/Api/LatestCommentsApiController.php
Normal 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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Api/Messaging/AttachmentController.php
Normal file
41
app/Http/Controllers/Api/Messaging/AttachmentController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
466
app/Http/Controllers/Api/Messaging/ConversationController.php
Normal file
466
app/Http/Controllers/Api/Messaging/ConversationController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
351
app/Http/Controllers/Api/Messaging/MessageController.php
Normal file
351
app/Http/Controllers/Api/Messaging/MessageController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
139
app/Http/Controllers/Api/Messaging/MessageSearchController.php
Normal file
139
app/Http/Controllers/Api/Messaging/MessageSearchController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/Api/Messaging/TypingController.php
Normal file
96
app/Http/Controllers/Api/Messaging/TypingController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/Http/Controllers/Api/ReactionController.php
Normal file
192
app/Http/Controllers/Api/ReactionController.php
Normal 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}].");
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Api/ReportController.php
Normal file
63
app/Http/Controllers/Api/ReportController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/Api/Search/UserSearchController.php
Normal file
59
app/Http/Controllers/Api/Search/UserSearchController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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'])
|
||||
// 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('created_at');
|
||||
->orderByDesc('artwork_comments.created_at')
|
||||
->paginate(self::PER_PAGE);
|
||||
});
|
||||
|
||||
$comments = $query->paginate($hits)->withQueryString();
|
||||
|
||||
$comments->getCollection()->transform(function (ArtworkComment $c) {
|
||||
$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) [
|
||||
return [
|
||||
'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,
|
||||
'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,
|
||||
'artwork_slug' => $art->slug ?? Str::slug($art->title ?? ''),
|
||||
'datetime' => $c->created_at?->toDateTimeString() ?? now()->toDateTimeString(),
|
||||
] : 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'));
|
||||
}
|
||||
}
|
||||
|
||||
33
app/Http/Controllers/Dashboard/DashboardAwardsController.php
Normal file
33
app/Http/Controllers/Dashboard/DashboardAwardsController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
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');
|
||||
|
||||
DB::table('artwork_favourites')
|
||||
->where('user_id', (int) $user->id)
|
||||
->where('artwork_id', $artworkId)
|
||||
->delete();
|
||||
|
||||
// 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();
|
||||
}
|
||||
if ($creatorId) {
|
||||
app(UserStatsService::class)->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
|
||||
return redirect()->route('dashboard.favorites')->with('status', 'favourite-removed');
|
||||
|
||||
@@ -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 = [];
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Http/Controllers/Messaging/MessagesPageController.php
Normal file
22
app/Http/Controllers/Messaging/MessagesPageController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
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
|
||||
{
|
||||
@@ -23,23 +23,7 @@ class FavouritesController extends Controller
|
||||
$results = collect();
|
||||
|
||||
try {
|
||||
$schema = DB::getSchemaBuilder();
|
||||
} 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'])
|
||||
$query = ArtworkFavourite::with(['artwork.user'])
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('artwork_id');
|
||||
@@ -48,13 +32,13 @@ class FavouritesController extends Controller
|
||||
|
||||
$favorites = $query->skip($start)->take($hits)->get();
|
||||
|
||||
$results = $favorites->map(function ($fav) use ($userNameCol) {
|
||||
$results = $favorites->map(function ($fav) {
|
||||
$art = $fav->artwork;
|
||||
if (! $art) {
|
||||
return null;
|
||||
}
|
||||
$item = (object) $art->toArray();
|
||||
$item->uname = ($userNameCol && isset($art->user)) ? ($art->user->{$userNameCol} ?? null) : null;
|
||||
$item->uname = $art->user?->username ?? $art->user?->name ?? null;
|
||||
$item->datum = $fav->created_at;
|
||||
return $item;
|
||||
})->filter();
|
||||
@@ -62,60 +46,16 @@ class FavouritesController extends Controller
|
||||
$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -74,34 +78,14 @@ class ProfileController extends Controller
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$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) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,44 +3,79 @@
|
||||
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();
|
||||
|
||||
// ── Strategy 1: legacy featured_works table (historical data from old site) ─
|
||||
$hasFeaturedWorks = false;
|
||||
try { $hasFeaturedWorks = Schema::hasTable('featured_works'); } catch (\Throwable) {}
|
||||
|
||||
if ($hasFeaturedWorks) {
|
||||
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'));
|
||||
|
||||
$artworks = $base->orderBy('t0.post_date','desc')->paginate($hits);
|
||||
$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'];
|
||||
}
|
||||
$row->gid_num = ((int)($row->category ?? 0) % 5) * 5;
|
||||
|
||||
// ── 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';
|
||||
}
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
@@ -48,6 +83,7 @@ class TodayInHistoryController extends Controller
|
||||
return view('legacy::today-in-history', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Popular on this day in history',
|
||||
'todayLabel' => $today->format('F j'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
243
app/Http/Controllers/Web/DiscoverController.php
Normal file
243
app/Http/Controllers/Web/DiscoverController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -43,12 +43,10 @@ class ArtworkResource extends JsonResource
|
||||
->exists();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_favorites')) {
|
||||
$isFavorited = DB::table('user_favorites')
|
||||
$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')
|
||||
|
||||
25
app/Jobs/DeleteMessageFromIndexJob.php
Normal file
25
app/Jobs/DeleteMessageFromIndexJob.php
Normal 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();
|
||||
}
|
||||
}
|
||||
36
app/Jobs/IndexMessageJob.php
Normal file
36
app/Jobs/IndexMessageJob.php
Normal 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
42
app/Jobs/IndexUserJob.php
Normal 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();
|
||||
}
|
||||
}
|
||||
36
app/Jobs/RecomputeUserStatsJob.php
Normal file
36
app/Jobs/RecomputeUserStatsJob.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,20 @@ class Artwork extends Model
|
||||
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
|
||||
}
|
||||
|
||||
/** All favourite pivot rows for this artwork. */
|
||||
public function favourites(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkFavourite::class, 'artwork_id');
|
||||
}
|
||||
|
||||
/** Users who have favourited this artwork (many-to-many shortcut). */
|
||||
public function favouritedBy(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'artwork_favourites', 'artwork_id', 'user_id')
|
||||
->withPivot('legacy_id')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function awards(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkAward::class);
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkComment
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property int $user_id
|
||||
* @property string|null $content Legacy plain-text column
|
||||
* @property string|null $raw_content User-submitted Markdown
|
||||
* @property string|null $rendered_content Cached sanitized HTML
|
||||
* @property bool $is_approved
|
||||
* @property-read Artwork $artwork
|
||||
* @property-read User $user
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|CommentReaction[] $reactions
|
||||
*/
|
||||
class ArtworkComment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'artwork_comments';
|
||||
|
||||
@@ -22,6 +32,8 @@ class ArtworkComment extends Model
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'content',
|
||||
'raw_content',
|
||||
'rendered_content',
|
||||
'is_approved',
|
||||
];
|
||||
|
||||
@@ -38,4 +50,24 @@ class ArtworkComment extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function reactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CommentReaction::class, 'comment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the best available rendered content for display.
|
||||
* Falls back to escaping raw legacy content if rendering isn't done yet.
|
||||
*/
|
||||
public function getDisplayHtml(): string
|
||||
{
|
||||
if ($this->rendered_content !== null) {
|
||||
return $this->rendered_content;
|
||||
}
|
||||
|
||||
// Lazy render: raw_content takes priority over legacy content
|
||||
$raw = $this->raw_content ?? $this->content ?? '';
|
||||
return \App\Services\ContentSanitizer::render($raw);
|
||||
}
|
||||
}
|
||||
|
||||
48
app/Models/ArtworkFavourite.php
Normal file
48
app/Models/ArtworkFavourite.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Represents a user's "favourite" bookmark on an artwork.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $artwork_id
|
||||
* @property int|null $legacy_id Original favourite_id from the old site
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*
|
||||
* @property-read User $user
|
||||
* @property-read Artwork $artwork
|
||||
*/
|
||||
class ArtworkFavourite extends Model
|
||||
{
|
||||
protected $table = 'artwork_favourites';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'artwork_id',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'artwork_id' => 'integer',
|
||||
'legacy_id' => 'integer',
|
||||
];
|
||||
|
||||
// ── Relations ──────────────────────────────────────────────────────────
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
}
|
||||
37
app/Models/ArtworkReaction.php
Normal file
37
app/Models/ArtworkReaction.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property int $user_id
|
||||
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
|
||||
*/
|
||||
class ArtworkReaction extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'artwork_reactions';
|
||||
|
||||
protected $fillable = ['artwork_id', 'user_id', 'reaction'];
|
||||
|
||||
protected $casts = [
|
||||
'artwork_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
37
app/Models/CommentReaction.php
Normal file
37
app/Models/CommentReaction.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $comment_id
|
||||
* @property int $user_id
|
||||
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
|
||||
*/
|
||||
class CommentReaction extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'comment_reactions';
|
||||
|
||||
protected $fillable = ['comment_id', 'user_id', 'reaction'];
|
||||
|
||||
protected $casts = [
|
||||
'comment_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function comment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ArtworkComment::class, 'comment_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
117
app/Models/Conversation.php
Normal file
117
app/Models/Conversation.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $type direct|group
|
||||
* @property string|null $title
|
||||
* @property int $created_by
|
||||
* @property \Carbon\Carbon|null $last_message_at
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class Conversation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'title',
|
||||
'created_by',
|
||||
'last_message_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_message_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────────────────
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function participants(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'conversation_participants')
|
||||
->withPivot(['role', 'last_read_at', 'is_muted', 'is_archived', 'is_pinned', 'pinned_at', 'joined_at', 'left_at'])
|
||||
->wherePivotNull('left_at');
|
||||
}
|
||||
|
||||
public function allParticipants(): HasMany
|
||||
{
|
||||
return $this->hasMany(ConversationParticipant::class);
|
||||
}
|
||||
|
||||
public function messages(): HasMany
|
||||
{
|
||||
return $this->hasMany(Message::class)->orderBy('created_at');
|
||||
}
|
||||
|
||||
public function latestMessage(): HasOne
|
||||
{
|
||||
return $this->hasOne(Message::class)->whereNull('deleted_at')->latestOfMany();
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return $this->type === 'direct';
|
||||
}
|
||||
|
||||
public function isGroup(): bool
|
||||
{
|
||||
return $this->type === 'group';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing direct conversation between exactly two users, or null.
|
||||
*/
|
||||
public static function findDirect(int $userA, int $userB): ?self
|
||||
{
|
||||
return self::query()
|
||||
->where('type', 'direct')
|
||||
->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userA)->whereNull('left_at'))
|
||||
->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userB)->whereNull('left_at'))
|
||||
->whereRaw(
|
||||
'(select count(*) from conversation_participants'
|
||||
.' where conversation_participants.conversation_id = conversations.id'
|
||||
.' and left_at is null) = 2'
|
||||
)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute unread count for a given participant.
|
||||
*/
|
||||
public function unreadCountFor(int $userId): int
|
||||
{
|
||||
$participant = $this->allParticipants()
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (! $participant) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$query = $this->messages()
|
||||
->whereNull('deleted_at')
|
||||
->where('sender_id', '!=', $userId);
|
||||
|
||||
if ($participant->last_read_at) {
|
||||
$query->where('created_at', '>', $participant->last_read_at);
|
||||
}
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
}
|
||||
62
app/Models/ConversationParticipant.php
Normal file
62
app/Models/ConversationParticipant.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $conversation_id
|
||||
* @property int $user_id
|
||||
* @property string $role member|admin
|
||||
* @property \Carbon\Carbon|null $last_read_at
|
||||
* @property bool $is_muted
|
||||
* @property bool $is_archived
|
||||
* @property bool $is_pinned
|
||||
* @property \Carbon\Carbon|null $pinned_at
|
||||
* @property \Carbon\Carbon $joined_at
|
||||
* @property \Carbon\Carbon|null $left_at
|
||||
*/
|
||||
class ConversationParticipant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'conversation_id',
|
||||
'user_id',
|
||||
'role',
|
||||
'last_read_at',
|
||||
'is_muted',
|
||||
'is_archived',
|
||||
'is_pinned',
|
||||
'pinned_at',
|
||||
'joined_at',
|
||||
'left_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_read_at' => 'datetime',
|
||||
'is_muted' => 'boolean',
|
||||
'is_archived' => 'boolean',
|
||||
'is_pinned' => 'boolean',
|
||||
'pinned_at' => 'datetime',
|
||||
'joined_at' => 'datetime',
|
||||
'left_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────────────────
|
||||
|
||||
public function conversation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Conversation::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
89
app/Models/Message.php
Normal file
89
app/Models/Message.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $conversation_id
|
||||
* @property int $sender_id
|
||||
* @property string $body
|
||||
* @property \Carbon\Carbon|null $edited_at
|
||||
* @property \Carbon\Carbon|null $deleted_at
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class Message extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, Searchable;
|
||||
|
||||
protected $fillable = [
|
||||
'conversation_id',
|
||||
'sender_id',
|
||||
'body',
|
||||
'edited_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'edited_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────────────────
|
||||
|
||||
public function conversation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Conversation::class);
|
||||
}
|
||||
|
||||
public function sender(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'sender_id');
|
||||
}
|
||||
|
||||
public function reactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(MessageReaction::class);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(MessageAttachment::class);
|
||||
}
|
||||
|
||||
public function setBodyAttribute(string $value): void
|
||||
{
|
||||
$sanitized = trim(strip_tags($value));
|
||||
$this->attributes['body'] = $sanitized;
|
||||
}
|
||||
|
||||
public function searchableAs(): string
|
||||
{
|
||||
return config('messaging.search.index', 'messages');
|
||||
}
|
||||
|
||||
public function shouldBeSearchable(): bool
|
||||
{
|
||||
return $this->deleted_at === null;
|
||||
}
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $this->id,
|
||||
'conversation_id' => (int) $this->conversation_id,
|
||||
'sender_id' => (int) $this->sender_id,
|
||||
'sender_username' => (string) ($this->sender?->username ?? ''),
|
||||
'body_text' => trim(strip_tags((string) $this->body)),
|
||||
'created_at' => optional($this->created_at)->timestamp ?? now()->timestamp,
|
||||
'has_attachments' => $this->relationLoaded('attachments')
|
||||
? $this->attachments->isNotEmpty()
|
||||
: $this->attachments()->exists(),
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Models/MessageAttachment.php
Normal file
45
app/Models/MessageAttachment.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MessageAttachment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'message_id',
|
||||
'user_id',
|
||||
'type',
|
||||
'mime',
|
||||
'size_bytes',
|
||||
'width',
|
||||
'height',
|
||||
'sha256',
|
||||
'original_name',
|
||||
'storage_path',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'size_bytes' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function message(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Message::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
43
app/Models/MessageReaction.php
Normal file
43
app/Models/MessageReaction.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $message_id
|
||||
* @property int $user_id
|
||||
* @property string $reaction
|
||||
* @property \Carbon\Carbon $created_at
|
||||
*/
|
||||
class MessageReaction extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'message_id',
|
||||
'user_id',
|
||||
'reaction',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────────────────
|
||||
|
||||
public function message(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Message::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
26
app/Models/Report.php
Normal file
26
app/Models/Report.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Report extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'reporter_id',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'reason',
|
||||
'details',
|
||||
'status',
|
||||
];
|
||||
|
||||
public function reporter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reporter_id');
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,19 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, SoftDeletes;
|
||||
use HasFactory, Notifiable, SoftDeletes, Searchable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -34,6 +39,7 @@ class User extends Authenticatable
|
||||
'needs_password_reset',
|
||||
'password',
|
||||
'role',
|
||||
'allow_messages_from',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -61,6 +67,7 @@ class User extends Authenticatable
|
||||
'username_changed_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'allow_messages_from' => 'string',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -106,11 +113,93 @@ class User extends Authenticatable
|
||||
return $this->hasMany(ProfileComment::class, 'profile_user_id');
|
||||
}
|
||||
|
||||
// ── Messaging ────────────────────────────────────────────────────────────
|
||||
|
||||
public function conversations(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Conversation::class, 'conversation_participants')
|
||||
->withPivot(['role', 'last_read_at', 'is_muted', 'is_archived', 'is_pinned', 'pinned_at', 'joined_at', 'left_at'])
|
||||
->wherePivotNull('left_at')
|
||||
->orderByPivot('joined_at', 'desc');
|
||||
}
|
||||
|
||||
public function conversationParticipants(): HasMany
|
||||
{
|
||||
return $this->hasMany(ConversationParticipant::class);
|
||||
}
|
||||
|
||||
public function sentMessages(): HasMany
|
||||
{
|
||||
return $this->hasMany(Message::class, 'sender_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this user allows receiving messages from the given user.
|
||||
*/
|
||||
public function allowsMessagesFrom(User $sender): bool
|
||||
{
|
||||
$pref = $this->allow_messages_from ?? 'everyone';
|
||||
|
||||
return match ($pref) {
|
||||
'everyone' => true,
|
||||
'followers' => $this->followers()->where('follower_id', $sender->id)->exists(),
|
||||
'mutual_followers' => $this->followers()->where('follower_id', $sender->id)->exists()
|
||||
&& $this->following()->where('user_id', $sender->id)->exists(),
|
||||
'nobody' => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Artworks this user has added to their favourites. */
|
||||
public function favouriteArtworks(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Artwork::class, 'artwork_favourites', 'user_id', 'artwork_id')
|
||||
->withPivot('legacy_id')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return strtolower((string) ($this->role ?? '')) === strtolower($role);
|
||||
}
|
||||
|
||||
// ─── Follow helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether $viewerId is following this user.
|
||||
* Uses a single indexed lookup – safe to call on every profile render.
|
||||
*/
|
||||
public function isFollowedBy(int $viewerId): bool
|
||||
{
|
||||
if ($viewerId === $this->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DB::table('user_followers')
|
||||
->where('user_id', $this->id)
|
||||
->where('follower_id', $viewerId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached follower count from user_statistics.
|
||||
* Returns 0 if the statistics row does not exist yet.
|
||||
*/
|
||||
public function getFollowersCountAttribute(): int
|
||||
{
|
||||
return (int) ($this->statistics?->followers_count ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached following count from user_statistics.
|
||||
*/
|
||||
public function getFollowingCountAttribute(): int
|
||||
{
|
||||
return (int) ($this->statistics?->following_count ?? 0);
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->hasRole('admin');
|
||||
@@ -120,4 +209,42 @@ class User extends Authenticatable
|
||||
{
|
||||
return $this->hasRole('moderator');
|
||||
}
|
||||
|
||||
// ─── Meilisearch ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Only index active users (not soft-deleted, is_active = true).
|
||||
*/
|
||||
public function shouldBeSearchable(): bool
|
||||
{
|
||||
return (bool) $this->is_active && ! $this->trashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Data indexed in Meilisearch.
|
||||
* Includes all v2 stat counters for top-creator sorting.
|
||||
*/
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
$stats = $this->statistics;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'username' => strtolower((string) ($this->username ?? '')),
|
||||
'name' => $this->name,
|
||||
// Upload activity
|
||||
'uploads_count' => (int) ($stats?->uploads_count ?? 0),
|
||||
// Creator-received metrics
|
||||
'downloads_received_count' => (int) ($stats?->downloads_received_count ?? 0),
|
||||
'artwork_views_received_count' => (int) ($stats?->artwork_views_received_count ?? 0),
|
||||
'awards_received_count' => (int) ($stats?->awards_received_count ?? 0),
|
||||
'favorites_received_count' => (int) ($stats?->favorites_received_count ?? 0),
|
||||
'comments_received_count' => (int) ($stats?->comments_received_count ?? 0),
|
||||
'reactions_received_count' => (int) ($stats?->reactions_received_count ?? 0),
|
||||
// Social
|
||||
'followers_count' => (int) ($stats?->followers_count ?? 0),
|
||||
'following_count' => (int) ($stats?->following_count ?? 0),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* User Statistics – v2 schema.
|
||||
*
|
||||
* Single row per user (user_id is PK).
|
||||
* All counters are unsignedBigInteger with default 0.
|
||||
*
|
||||
* All updates MUST go through App\Services\UserStatsService.
|
||||
*/
|
||||
class UserStatistic extends Model
|
||||
{
|
||||
protected $table = 'user_statistics';
|
||||
@@ -14,11 +24,44 @@ class UserStatistic extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'uploads',
|
||||
'downloads',
|
||||
'pageviews',
|
||||
'awards',
|
||||
'profile_views',
|
||||
|
||||
// Creator upload activity
|
||||
'uploads_count',
|
||||
|
||||
// Creator-received metrics
|
||||
'downloads_received_count',
|
||||
'artwork_views_received_count',
|
||||
'awards_received_count',
|
||||
'favorites_received_count',
|
||||
'comments_received_count',
|
||||
'reactions_received_count',
|
||||
|
||||
// Social stats (managed by FollowService)
|
||||
'followers_count',
|
||||
'following_count',
|
||||
|
||||
// Profile / discovery
|
||||
'profile_views_count',
|
||||
|
||||
// Activity timestamps
|
||||
'last_upload_at',
|
||||
'last_active_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'uploads_count' => 'integer',
|
||||
'downloads_received_count' => 'integer',
|
||||
'artwork_views_received_count' => 'integer',
|
||||
'awards_received_count' => 'integer',
|
||||
'favorites_received_count' => 'integer',
|
||||
'comments_received_count' => 'integer',
|
||||
'reactions_received_count' => 'integer',
|
||||
'followers_count' => 'integer',
|
||||
'following_count' => 'integer',
|
||||
'profile_views_count' => 'integer',
|
||||
'last_upload_at' => 'datetime',
|
||||
'last_active_at' => 'datetime',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
app/Observers/ArtworkCommentObserver.php
Normal file
63
app/Observers/ArtworkCommentObserver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
45
app/Observers/ArtworkFavouriteObserver.php
Normal file
45
app/Observers/ArtworkFavouriteObserver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
49
app/Observers/ArtworkReactionObserver.php
Normal file
49
app/Observers/ArtworkReactionObserver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
app/Policies/ArtworkCommentPolicy.php
Normal file
25
app/Policies/ArtworkCommentPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$favCount = DB::table('favourites')->where('user_id', $userId)->count();
|
||||
$favCount = DB::table('artwork_favourites')->where('user_id', $userId)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$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()),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -18,6 +20,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
protected $policies = [
|
||||
Artwork::class => ArtworkPolicy::class,
|
||||
ArtworkAward::class => ArtworkAwardPolicy::class,
|
||||
ArtworkComment::class => ArtworkCommentPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -69,16 +69,22 @@ 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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate and persist stats for the given artwork.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
@@ -25,6 +29,7 @@ class ArtworkStatsService
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'views', $by);
|
||||
return;
|
||||
}
|
||||
$this->applyDelta($artworkId, ['views' => $by]);
|
||||
}
|
||||
|
||||
@@ -36,21 +41,30 @@ class ArtworkStatsService
|
||||
if ($defer && $this->redisAvailable()) {
|
||||
$this->pushDelta($artworkId, 'downloads', $by);
|
||||
return;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* This method is safe to call from jobs or synchronously.
|
||||
* 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
|
||||
@@ -59,17 +73,9 @@ class ArtworkStatsService
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($artworkId, $deltas) {
|
||||
// Ensure a stats row exists. Insert default zeros if missing.
|
||||
// Ensure a stats row exists — insert default zeros if missing.
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artworkId,
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -78,7 +84,7 @@ class ArtworkStatsService
|
||||
]);
|
||||
|
||||
foreach ($deltas as $column => $value) {
|
||||
// Only allow known columns to avoid SQL injection
|
||||
// Only allow known columns to avoid SQL injection.
|
||||
if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) {
|
||||
continue;
|
||||
}
|
||||
@@ -88,8 +94,57 @@ class ArtworkStatsService
|
||||
->increment($column, (int) $value);
|
||||
}
|
||||
});
|
||||
|
||||
// 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()]);
|
||||
Log::error('Failed to apply artwork stats delta', [
|
||||
'artwork_id' => $artworkId,
|
||||
'deltas' => $deltas,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
if ($viewDelta <= 0 && $downloadDelta <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,8 +163,10 @@ class ArtworkStatsService
|
||||
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()]);
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +180,7 @@ class ArtworkStatsService
|
||||
if (! $this->redisAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
|
||||
try {
|
||||
@@ -135,6 +193,8 @@ class ArtworkStatsService
|
||||
$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++;
|
||||
}
|
||||
@@ -148,13 +208,10 @@ class ArtworkStatsService
|
||||
protected function redisAvailable(): bool
|
||||
{
|
||||
try {
|
||||
// Redis facade may throw if not configured
|
||||
$pong = Redis::connection()->ping();
|
||||
return (bool) $pong;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
323
app/Services/ContentSanitizer.php
Normal file
323
app/Services/ContentSanitizer.php
Normal 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. & → &)
|
||||
$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;
|
||||
}
|
||||
}
|
||||
144
app/Services/FollowService.php
Normal file
144
app/Services/FollowService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
167
app/Services/LegacySmileyMapper.php
Normal file
167
app/Services/LegacySmileyMapper.php
Normal 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;
|
||||
}
|
||||
}
|
||||
68
app/Services/Messaging/MessageNotificationService.php
Normal file
68
app/Services/Messaging/MessageNotificationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
app/Services/Messaging/MessageSearchIndexer.php
Normal file
50
app/Services/Messaging/MessageSearchIndexer.php
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
290
app/Services/UserStatsService.php
Normal file
290
app/Services/UserStatsService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
2
composer.lock
generated
@@ -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
28
config/messaging.php
Normal 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'],
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
32
database/factories/ArtworkCommentFactory.php
Normal file
32
database/factories/ArtworkCommentFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
1314
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -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 |
@@ -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
Reference in New Issue
Block a user