Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz
This commit is contained in:
@@ -11,7 +11,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class ImportLegacyUsers extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--dry-run : Preview which users would be skipped/deleted without making changes}';
|
||||
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--restore-temp-usernames : Restore legacy usernames for existing users still using tmpu12345-style placeholders} {--dry-run : Preview which users would be skipped/deleted without making changes}';
|
||||
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
|
||||
|
||||
protected string $migrationLogPath;
|
||||
@@ -20,7 +20,7 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->migrationLogPath = storage_path('logs/username_migration.log');
|
||||
$this->migrationLogPath = (string) storage_path('logs/username_migration.log');
|
||||
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
|
||||
|
||||
// Build the set of legacy user IDs that have any meaningful activity.
|
||||
@@ -134,8 +134,14 @@ class ImportLegacyUsers extends Command
|
||||
{
|
||||
$legacyId = (int) $row->user_id;
|
||||
|
||||
// Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB).
|
||||
$username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
|
||||
// Use legacy username as-is by default. Placeholder tmp usernames can be
|
||||
// restored explicitly with --restore-temp-usernames using safe uniqueness rules.
|
||||
$existingUser = DB::table('users')
|
||||
->select(['id', 'username'])
|
||||
->where('id', $legacyId)
|
||||
->first();
|
||||
|
||||
$username = $this->resolveImportUsername($row, $legacyId, $existingUser?->username ?? null);
|
||||
|
||||
$normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
||||
if ($normalizedLegacy !== $username) {
|
||||
@@ -173,7 +179,12 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
||||
$now = now();
|
||||
$alreadyExists = DB::table('users')->where('id', $legacyId)->exists();
|
||||
$existingUser = DB::table('users')
|
||||
->select(['id', 'username'])
|
||||
->where('id', $legacyId)
|
||||
->first();
|
||||
$alreadyExists = $existingUser !== null;
|
||||
$previousUsername = (string) ($existingUser?->username ?? '');
|
||||
|
||||
// All fields synced from legacy on every run
|
||||
$sharedFields = [
|
||||
@@ -212,7 +223,7 @@ class ImportLegacyUsers extends Command
|
||||
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
||||
'language' => $row->lang ?: null,
|
||||
'birthdate' => $row->birth ?: null,
|
||||
'gender' => $row->gender ?: 'X',
|
||||
'gender' => $this->normalizeLegacyGender($row->gender ?? null),
|
||||
'website' => $row->web ?: null,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
@@ -232,7 +243,7 @@ class ImportLegacyUsers extends Command
|
||||
);
|
||||
|
||||
if (Schema::hasTable('username_redirects')) {
|
||||
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
||||
$old = $this->usernameRedirectKey((string) ($row->uname ?? ''));
|
||||
if ($old !== '' && $old !== $username) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $old],
|
||||
@@ -244,10 +255,50 @@ class ImportLegacyUsers extends Command
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->shouldRestoreTemporaryUsername($previousUsername) && $previousUsername !== $username) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $this->usernameRedirectKey($previousUsername)],
|
||||
[
|
||||
'new_username' => $username,
|
||||
'user_id' => $legacyId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function resolveImportUsername(object $row, int $legacyId, ?string $existingUsername = null): string
|
||||
{
|
||||
$legacyUsername = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
|
||||
|
||||
if (! $this->option('restore-temp-usernames')) {
|
||||
return $legacyUsername;
|
||||
}
|
||||
|
||||
if ($existingUsername === null || $existingUsername === '') {
|
||||
return $legacyUsername;
|
||||
}
|
||||
|
||||
if (! $this->shouldRestoreTemporaryUsername($existingUsername)) {
|
||||
return $existingUsername;
|
||||
}
|
||||
|
||||
return UsernamePolicy::uniqueCandidate((string) ($row->uname ?: ('user' . $legacyId)), $legacyId);
|
||||
}
|
||||
|
||||
protected function shouldRestoreTemporaryUsername(?string $username): bool
|
||||
{
|
||||
if (! is_string($username) || trim($username) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('/^tmpu\d+$/i', trim($username)) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure statistic values are safe for unsigned DB columns.
|
||||
*/
|
||||
@@ -265,6 +316,24 @@ class ImportLegacyUsers extends Command
|
||||
return UsernamePolicy::sanitizeLegacy($username);
|
||||
}
|
||||
|
||||
protected function usernameRedirectKey(?string $username): string
|
||||
{
|
||||
$value = $this->sanitizeUsername((string) ($username ?? ''));
|
||||
|
||||
return $value === 'user' && trim((string) ($username ?? '')) === '' ? '' : $value;
|
||||
}
|
||||
|
||||
protected function normalizeLegacyGender(mixed $value): ?string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) ($value ?? '')));
|
||||
|
||||
return match ($normalized) {
|
||||
'M', 'MALE', 'MAN', 'BOY' => 'M',
|
||||
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
protected function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim($value));
|
||||
|
||||
184
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal file
184
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use App\Services\Vision\VectorGatewayClient;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class IndexArtworkVectorsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:vectors-index
|
||||
{--start-id=0 : Start from this artwork id (inclusive)}
|
||||
{--after-id=0 : Resume after this artwork id}
|
||||
{--batch=100 : Batch size per iteration}
|
||||
{--limit=0 : Maximum artworks to process in this run}
|
||||
{--public-only : Index only public, approved, published artworks}
|
||||
{--dry-run : Preview requests without sending them}';
|
||||
|
||||
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
|
||||
|
||||
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if (! $dryRun && ! $client->isConfigured()) {
|
||||
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$startId = max(0, (int) $this->option('start-id'));
|
||||
$afterId = max(0, (int) $this->option('after-id'));
|
||||
$batch = max(1, min((int) $this->option('batch'), 1000));
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
$publicOnly = (bool) $this->option('public-only');
|
||||
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
|
||||
|
||||
$processed = 0;
|
||||
$indexed = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$lastId = $afterId;
|
||||
|
||||
if ($startId > 0 && $afterId > 0) {
|
||||
$this->warn(sprintf(
|
||||
'Both --start-id=%d and --after-id=%d were provided. Using --start-id and ignoring --after-id.',
|
||||
$startId,
|
||||
$afterId
|
||||
));
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s public_only=%s dry_run=%s',
|
||||
$startId,
|
||||
$afterId,
|
||||
$nextId,
|
||||
$batch,
|
||||
$limit > 0 ? (string) $limit : 'all',
|
||||
$publicOnly ? 'yes' : 'no',
|
||||
$dryRun ? 'yes' : 'no'
|
||||
));
|
||||
|
||||
while (true) {
|
||||
$remaining = $limit > 0 ? max(0, $limit - $processed) : $batch;
|
||||
if ($limit > 0 && $remaining === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$take = $limit > 0 ? min($batch, $remaining) : $batch;
|
||||
|
||||
$query = Artwork::query()
|
||||
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||
->where('id', '>=', $nextId)
|
||||
->whereNotNull('hash')
|
||||
->orderBy('id')
|
||||
->limit($take);
|
||||
|
||||
if ($publicOnly) {
|
||||
$query->public()->published();
|
||||
}
|
||||
|
||||
$artworks = $query->get();
|
||||
if ($artworks->isEmpty()) {
|
||||
$this->line('No more artworks matched the current query window.');
|
||||
break;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'Fetched batch: count=%d first_id=%d last_id=%d',
|
||||
$artworks->count(),
|
||||
(int) $artworks->first()->id,
|
||||
(int) $artworks->last()->id
|
||||
));
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
$processed++;
|
||||
$lastId = (int) $artwork->id;
|
||||
$nextId = $lastId + 1;
|
||||
|
||||
$url = $imageUrl->fromArtwork($artwork);
|
||||
if ($url === null) {
|
||||
$skipped++;
|
||||
$this->warn("Skipped artwork {$artwork->id}: no vision image URL could be generated.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$metadata = $this->metadataForArtwork($artwork);
|
||||
$this->line(sprintf(
|
||||
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
|
||||
(int) $artwork->id,
|
||||
(string) ($artwork->hash ?? ''),
|
||||
(string) ($artwork->thumb_ext ?? ''),
|
||||
$url,
|
||||
$this->json($metadata)
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$indexed++;
|
||||
$this->line(sprintf(
|
||||
'[dry] artwork=%d indexed=%d/%d',
|
||||
(int) $artwork->id,
|
||||
$indexed,
|
||||
$processed
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->upsertByUrl($url, (int) $artwork->id, $metadata);
|
||||
$indexed++;
|
||||
$this->info(sprintf(
|
||||
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
|
||||
(int) $artwork->id,
|
||||
$processed,
|
||||
$indexed,
|
||||
$skipped,
|
||||
$failed
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn("Failed artwork {$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}");
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $payload
|
||||
*/
|
||||
private function json(array $payload): string
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($json) ? $json : '{}';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content_type: string, category: string, user_id: string}
|
||||
*/
|
||||
private function metadataForArtwork(Artwork $artwork): array
|
||||
{
|
||||
$category = $this->primaryCategory($artwork);
|
||||
|
||||
return [
|
||||
'content_type' => (string) ($category?->contentType?->name ?? ''),
|
||||
'category' => (string) ($category?->name ?? ''),
|
||||
'user_id' => (string) ($artwork->user_id ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function primaryCategory(Artwork $artwork): ?Category
|
||||
{
|
||||
/** @var Category|null $category */
|
||||
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal file
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class RepairLegacyWallzUsersCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:repair-legacy-wallz-users
|
||||
{--chunk=500 : Number of legacy wallz rows to scan per batch}
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-table=wallz : Legacy table to update}
|
||||
{--artworks-table=artworks : Current DB artworks table name}
|
||||
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
|
||||
{--dry-run : Preview matches and inserts without writing changes}';
|
||||
|
||||
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$legacyConnection = (string) $this->option('legacy-connection');
|
||||
$legacyTable = (string) $this->option('legacy-table');
|
||||
$artworksTable = (string) $this->option('artworks-table');
|
||||
$fixArtworks = (bool) $this->option('fix-artworks');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
|
||||
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No changes will be written.');
|
||||
}
|
||||
|
||||
if ($fixArtworks) {
|
||||
$this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun);
|
||||
}
|
||||
|
||||
$total = (int) DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->where('user_id', 0)
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
if (! $fixArtworks) {
|
||||
$this->info('No legacy wallz rows with user_id = 0 were found.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}.");
|
||||
|
||||
$processed = 0;
|
||||
$updatedRows = 0;
|
||||
$matchedUsers = 0;
|
||||
$createdUsers = 0;
|
||||
$skippedRows = 0;
|
||||
$usernameMap = [];
|
||||
|
||||
DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->select(['id', 'uname'])
|
||||
->where('user_id', 0)
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($rows) use (
|
||||
&$processed,
|
||||
&$updatedRows,
|
||||
&$matchedUsers,
|
||||
&$createdUsers,
|
||||
&$skippedRows,
|
||||
&$usernameMap,
|
||||
$dryRun,
|
||||
$legacyConnection,
|
||||
$legacyTable
|
||||
) {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$rawUsername = trim((string) ($row->uname ?? ''));
|
||||
if ($rawUsername === '') {
|
||||
$skippedRows++;
|
||||
$this->warn("Skipping wallz id={$row->id}: uname is empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$lookupKey = UsernamePolicy::normalize($rawUsername);
|
||||
if ($lookupKey === '') {
|
||||
$skippedRows++;
|
||||
$this->warn("Skipping wallz id={$row->id}: uname normalizes to empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($lookupKey, $usernameMap)) {
|
||||
$existingUser = $this->findUserByUsername($lookupKey);
|
||||
|
||||
if ($existingUser !== null) {
|
||||
$usernameMap[$lookupKey] = [
|
||||
'user_id' => (int) $existingUser->id,
|
||||
'created' => false,
|
||||
];
|
||||
} else {
|
||||
$usernameMap[$lookupKey] = [
|
||||
'user_id' => $dryRun
|
||||
? 0
|
||||
: $this->createUserForLegacyUsername($rawUsername, $legacyConnection),
|
||||
'created' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = $usernameMap[$lookupKey];
|
||||
|
||||
if ($resolved['created']) {
|
||||
$createdUsers++;
|
||||
$usernameMap[$lookupKey]['created'] = false;
|
||||
$resolved['created'] = false;
|
||||
$this->line($dryRun
|
||||
? "[dry] Would create user for uname='{$rawUsername}'"
|
||||
: "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'");
|
||||
} else {
|
||||
$matchedUsers++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$targetUser = $usernameMap[$lookupKey]['user_id'] > 0
|
||||
? (string) $usernameMap[$lookupKey]['user_id']
|
||||
: '<new-user-id>';
|
||||
$this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'");
|
||||
$updatedRows++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$affected = DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->where('id', $row->id)
|
||||
->where('user_id', 0)
|
||||
->update([
|
||||
'user_id' => $usernameMap[$lookupKey]['user_id'],
|
||||
]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$updatedRows += $affected;
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$this->info(sprintf(
|
||||
'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d',
|
||||
$processed,
|
||||
$updatedRows,
|
||||
$matchedUsers,
|
||||
$createdUsers,
|
||||
$skippedRows
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void
|
||||
{
|
||||
$this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0");
|
||||
|
||||
$total = (int) DB::table($artworksTable)->where('user_id', 0)->count();
|
||||
$this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}.");
|
||||
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
DB::table($artworksTable)
|
||||
->select(['id'])
|
||||
->where('user_id', 0)
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$legacyUser = DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->where('id', $row->id)
|
||||
->value('user_id');
|
||||
|
||||
$legacyUser = (int) ($legacyUser ?? 0);
|
||||
if ($legacyUser <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}");
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$affected = DB::table($artworksTable)
|
||||
->where('id', $row->id)
|
||||
->where('user_id', 0)
|
||||
->update(['user_id' => $legacyUser]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$updated += $affected;
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated));
|
||||
}
|
||||
|
||||
private function legacyTableExists(string $connection, string $table): bool
|
||||
{
|
||||
try {
|
||||
return DB::connection($connection)->getSchemaBuilder()->hasTable($table);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function findUserByUsername(string $normalizedUsername): ?object
|
||||
{
|
||||
return DB::table('users')
|
||||
->select(['id', 'username'])
|
||||
->whereRaw('LOWER(username) = ?', [$normalizedUsername])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int
|
||||
{
|
||||
$username = UsernamePolicy::uniqueCandidate($legacyUsername);
|
||||
$emailLocal = $this->sanitizeEmailLocal($username);
|
||||
$email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org');
|
||||
$now = now();
|
||||
|
||||
// Attempt to copy legacy joinDate from the legacy `users` table when available.
|
||||
$legacyJoin = null;
|
||||
try {
|
||||
$legacyJoin = DB::connection($legacyConnection)
|
||||
->table('users')
|
||||
->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)])
|
||||
->value('joinDate');
|
||||
} catch (\Throwable) {
|
||||
$legacyJoin = null;
|
||||
}
|
||||
|
||||
$createdAt = $now;
|
||||
if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) {
|
||||
try {
|
||||
$createdAt = Carbon::parse($legacyJoin);
|
||||
} catch (\Throwable) {
|
||||
$createdAt = $now;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = (int) DB::table('users')->insertGetId([
|
||||
'username' => $username,
|
||||
'username_changed_at' => $now,
|
||||
'name' => $legacyUsername,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => true,
|
||||
'needs_password_reset' => true,
|
||||
'role' => 'user',
|
||||
'legacy_password_algo' => null,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
private function uniqueEmailCandidate(string $email): string
|
||||
{
|
||||
$candidate = strtolower(trim($email));
|
||||
$suffix = 1;
|
||||
|
||||
while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) {
|
||||
$parts = explode('@', $email, 2);
|
||||
$local = $parts[0] ?? 'user';
|
||||
$domain = $parts[1] ?? 'users.skinbase.org';
|
||||
$candidate = $local . '+' . $suffix . '@' . $domain;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim($value));
|
||||
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
||||
|
||||
return trim($local, '.-') ?: 'user';
|
||||
}
|
||||
}
|
||||
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal file
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RepairTemporaryUsernamesCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:repair-temp-usernames
|
||||
{--chunk=500 : Number of users to process per batch}
|
||||
{--dry-run : Preview username changes without writing them}';
|
||||
|
||||
protected $description = 'Replace current users.username values like tmpu% using the users.name field';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No changes will be written.');
|
||||
}
|
||||
|
||||
$total = (int) DB::table('users')
|
||||
->where('username', 'like', 'tmpu%')
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('No users with temporary tmpu% usernames were found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$total} users with temporary tmpu% usernames.");
|
||||
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
DB::table('users')
|
||||
->select(['id', 'name', 'username'])
|
||||
->where('username', 'like', 'tmpu%')
|
||||
->chunkById($chunk, function ($rows) use (&$processed, &$updated, &$skipped, $dryRun) {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$sourceName = trim((string) ($row->name ?? ''));
|
||||
if ($sourceName === '') {
|
||||
$skipped++;
|
||||
$this->warn("Skipping user id={$row->id}: name is empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $this->resolveCandidate($sourceName, (int) $row->id);
|
||||
|
||||
if ($candidate === null || strcasecmp($candidate, (string) $row->username) === 0) {
|
||||
$skipped++;
|
||||
$this->warn("Skipping user id={$row->id}: unable to resolve a better username from name='{$sourceName}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[dry] Would update user id={$row->id} username '{$row->username}' => '{$candidate}'");
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$affected = DB::table('users')
|
||||
->where('id', (int) $row->id)
|
||||
->where('username', 'like', 'tmpu%')
|
||||
->update([
|
||||
'username' => $candidate,
|
||||
'username_changed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$updated += $affected;
|
||||
$this->line("[update] user id={$row->id} username '{$row->username}' => '{$candidate}'");
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$this->info(sprintf('Finished. processed=%d updated=%d skipped=%d', $processed, $updated, $skipped));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveCandidate(string $sourceName, int $userId): ?string
|
||||
{
|
||||
$base = UsernamePolicy::sanitizeLegacy($sourceName);
|
||||
$min = UsernamePolicy::min();
|
||||
$max = UsernamePolicy::max();
|
||||
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^tmpu\d+$/i', $base) === 1) {
|
||||
$base = 'user' . $userId;
|
||||
}
|
||||
|
||||
if (strlen($base) < $min) {
|
||||
$base = substr($base . $userId, 0, $max);
|
||||
}
|
||||
|
||||
if ($base === '' || $base === 'user') {
|
||||
$base = 'user' . $userId;
|
||||
}
|
||||
|
||||
$candidate = substr($base, 0, $max);
|
||||
$suffix = 1;
|
||||
|
||||
while ($this->usernameExists($candidate, $userId) || UsernamePolicy::isReserved($candidate)) {
|
||||
$suffixValue = (string) $suffix;
|
||||
$prefixLen = max(1, $max - strlen($suffixValue));
|
||||
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function usernameExists(string $username, int $ignoreUserId): bool
|
||||
{
|
||||
return DB::table('users')
|
||||
->whereRaw('LOWER(username) = ?', [strtolower($username)])
|
||||
->where('id', '!=', $ignoreUserId)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
118
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal file
118
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use App\Services\Vision\VectorGatewayClient;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class SearchArtworkVectorsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:vectors-search
|
||||
{artwork_id : Source artwork id}
|
||||
{--limit=5 : Number of similar artworks to return}';
|
||||
|
||||
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
|
||||
|
||||
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
|
||||
{
|
||||
if (! $client->isConfigured()) {
|
||||
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$artworkId = max(1, (int) $this->argument('artwork_id'));
|
||||
$limit = max(1, min((int) $this->option('limit'), 100));
|
||||
|
||||
$artwork = Artwork::query()
|
||||
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||
->find($artworkId);
|
||||
|
||||
if (! $artwork) {
|
||||
$this->error("Artwork {$artworkId} was not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$url = $imageUrl->fromArtwork($artwork);
|
||||
if ($url === null) {
|
||||
$this->error("Artwork {$artworkId} does not have a usable CDN image URL.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$matches = $client->searchByUrl($url, $limit + 1);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Vector search failed: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$ids = collect($matches)
|
||||
->map(fn (array $match): int => (int) $match['id'])
|
||||
->filter(fn (int $id): bool => $id > 0 && $id !== $artworkId)
|
||||
->unique()
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($ids === []) {
|
||||
$this->warn('No similar artworks were returned by the vector gateway.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||
->whereIn('id', $ids)
|
||||
->public()
|
||||
->published()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($matches as $match) {
|
||||
$matchId = (int) ($match['id'] ?? 0);
|
||||
if ($matchId <= 0 || $matchId === $artworkId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var Artwork|null $matchedArtwork */
|
||||
$matchedArtwork = $artworks->get($matchId);
|
||||
if (! $matchedArtwork) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$category = $this->primaryCategory($matchedArtwork);
|
||||
$rows[] = [
|
||||
'id' => $matchId,
|
||||
'score' => number_format((float) ($match['score'] ?? 0.0), 4, '.', ''),
|
||||
'title' => (string) $matchedArtwork->title,
|
||||
'content_type' => (string) ($category?->contentType?->name ?? ''),
|
||||
'category' => (string) ($category?->name ?? ''),
|
||||
];
|
||||
|
||||
if (count($rows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$this->warn('The vector gateway returned matches, but none resolved to public published artworks.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(['ID', 'Score', 'Title', 'Content Type', 'Category'], $rows);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function primaryCategory(Artwork $artwork): ?Category
|
||||
{
|
||||
/** @var Category|null $category */
|
||||
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Console\Commands\ImportLegacyUsers;
|
||||
use App\Console\Commands\ImportCategories;
|
||||
use App\Console\Commands\MigrateFeaturedWorks;
|
||||
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||
use App\Console\Commands\IndexArtworkVectorsCommand;
|
||||
use App\Console\Commands\SearchArtworkVectorsCommand;
|
||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||
@@ -43,6 +45,8 @@ class Kernel extends ConsoleKernel
|
||||
CleanupUploadsCommand::class,
|
||||
PublishScheduledArtworksCommand::class,
|
||||
BackfillArtworkEmbeddingsCommand::class,
|
||||
IndexArtworkVectorsCommand::class,
|
||||
SearchArtworkVectorsCommand::class,
|
||||
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||
AggregateFeedAnalyticsCommand::class,
|
||||
AggregateTagInteractionAnalyticsCommand::class,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Events;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use App\Services\Messaging\UnreadCounterService;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
@@ -41,6 +42,9 @@ class ConversationUpdated implements ShouldBroadcast
|
||||
'event' => 'conversation.updated',
|
||||
'reason' => $this->reason,
|
||||
'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId),
|
||||
'summary' => [
|
||||
'unread_total' => app(UnreadCounterService::class)->totalUnreadForUser($this->userId),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,4 @@ class MessageCreated implements ShouldBroadcast
|
||||
'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,4 @@ class MessageDeleted implements ShouldBroadcast
|
||||
'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,4 @@ class MessageRead implements ShouldBroadcast
|
||||
'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,4 @@ class MessageUpdated implements ShouldBroadcast
|
||||
'message' => app(MessagingPayloadFactory::class)->message($this->message),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ class ArtworkCommentController extends Controller
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')),
|
||||
'rendered_content' => $this->renderCommentContent($c),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'can_edit' => $currentUserId === $userId,
|
||||
@@ -224,6 +224,31 @@ class ArtworkCommentController extends Controller
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function renderCommentContent(ArtworkComment $comment): string
|
||||
{
|
||||
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
|
||||
$renderedContent = $comment->rendered_content;
|
||||
|
||||
if (! is_string($renderedContent) || trim($renderedContent) === '') {
|
||||
$renderedContent = $rawContent !== ''
|
||||
? ContentSanitizer::render($rawContent)
|
||||
: nl2br(e(strip_tags((string) ($comment->content ?? ''))));
|
||||
}
|
||||
|
||||
return ContentSanitizer::sanitizeRenderedHtml(
|
||||
$renderedContent,
|
||||
$this->commentAuthorCanPublishLinks($comment)
|
||||
);
|
||||
}
|
||||
|
||||
private function commentAuthorCanPublishLinks(ArtworkComment $comment): bool
|
||||
{
|
||||
$level = (int) ($comment->user?->level ?? 1);
|
||||
$rank = strtolower((string) ($comment->user?->rank ?? 'Newbie'));
|
||||
|
||||
return $level > 1 && $rank !== 'newbie';
|
||||
}
|
||||
|
||||
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
|
||||
{
|
||||
$notifiedUserIds = [];
|
||||
|
||||
@@ -9,10 +9,11 @@ use App\Http\Requests\Messaging\RenameConversationRequest;
|
||||
use App\Http\Requests\Messaging\StoreConversationRequest;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use App\Services\Messaging\ConversationReadService;
|
||||
use App\Services\Messaging\ConversationStateService;
|
||||
use App\Services\Messaging\SendMessageAction;
|
||||
use App\Services\Messaging\UnreadCounterService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -23,7 +24,9 @@ class ConversationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly ConversationReadService $conversationReads,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
private readonly UnreadCounterService $unreadCounters,
|
||||
) {}
|
||||
|
||||
// ── GET /api/messages/conversations ─────────────────────────────────────
|
||||
@@ -36,26 +39,13 @@ class ConversationController extends Controller
|
||||
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
|
||||
|
||||
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
|
||||
return Conversation::query()
|
||||
$query = 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_message_id')
|
||||
->whereNull('cp_me.last_read_at')
|
||||
->orWhereColumn('messages.id', '>', 'cp_me.last_read_message_id')
|
||||
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
|
||||
}),
|
||||
])
|
||||
->where('conversations.is_active', true)
|
||||
->with([
|
||||
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
|
||||
@@ -64,8 +54,11 @@ class ConversationController extends Controller
|
||||
->orderByDesc('cp_me.is_pinned')
|
||||
->orderByDesc('cp_me.pinned_at')
|
||||
->orderByDesc('last_message_at')
|
||||
->orderByDesc('conversations.id')
|
||||
->paginate(20, ['conversations.*'], 'page', $page);
|
||||
->orderByDesc('conversations.id');
|
||||
|
||||
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
|
||||
|
||||
return $query->paginate(20, ['conversations.*'], 'page', $page);
|
||||
});
|
||||
|
||||
$conversations->through(function ($conv) use ($user) {
|
||||
@@ -74,7 +67,12 @@ class ConversationController extends Controller
|
||||
return $conv;
|
||||
});
|
||||
|
||||
return response()->json($conversations);
|
||||
return response()->json([
|
||||
...$conversations->toArray(),
|
||||
'summary' => [
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
|
||||
@@ -110,7 +108,7 @@ class ConversationController extends Controller
|
||||
public function markRead(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->conversationState->markConversationRead(
|
||||
$participant = $this->conversationReads->markConversationRead(
|
||||
$conversation,
|
||||
$request->user(),
|
||||
$request->integer('message_id') ?: null,
|
||||
@@ -120,6 +118,7 @@ class ConversationController extends Controller
|
||||
'ok' => true,
|
||||
'last_read_at' => optional($participant->last_read_at)?->toIso8601String(),
|
||||
'last_read_message_id' => $participant->last_read_message_id,
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\MessageReaction;
|
||||
use App\Services\Messaging\ConversationDeltaService;
|
||||
use App\Services\Messaging\ConversationStateService;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use App\Services\Messaging\MessageSearchIndexer;
|
||||
@@ -26,6 +27,7 @@ class MessageController extends Controller
|
||||
private const PAGE_SIZE = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConversationDeltaService $conversationDelta,
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly MessagingPayloadFactory $payloadFactory,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
@@ -40,15 +42,7 @@ class MessageController extends Controller
|
||||
$afterId = $request->integer('after_id');
|
||||
|
||||
if ($afterId) {
|
||||
$messages = Message::withTrashed()
|
||||
->where('conversation_id', $conversationId)
|
||||
->with(['sender:id,username', 'reactions', 'attachments'])
|
||||
->where('id', '>', $afterId)
|
||||
->orderBy('id')
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))
|
||||
->values();
|
||||
$messages = $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterId);
|
||||
|
||||
return response()->json([
|
||||
'data' => $messages,
|
||||
@@ -77,6 +71,18 @@ class MessageController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function delta(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$conversation = $this->findConversationOrFail($conversationId);
|
||||
$afterMessageId = max(0, (int) $request->integer('after_message_id'));
|
||||
|
||||
abort_if($afterMessageId < 1, 422, 'after_message_id is required.');
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterMessageId),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
|
||||
|
||||
public function store(StoreMessageRequest $request, int $conversationId): JsonResponse
|
||||
|
||||
33
app/Http/Controllers/Api/Messaging/PresenceController.php
Normal file
33
app/Http/Controllers/Api/Messaging/PresenceController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Services\Messaging\MessagingPresenceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PresenceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessagingPresenceService $presence,
|
||||
) {}
|
||||
|
||||
public function heartbeat(Request $request): JsonResponse
|
||||
{
|
||||
$conversationId = $request->integer('conversation_id') ?: null;
|
||||
|
||||
if ($conversationId) {
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
}
|
||||
|
||||
$this->presence->touch($request->user(), $conversationId);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'conversation_id' => $conversationId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,14 @@ final class ProfileApiController extends Controller
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$sort = $request->input('sort', 'latest');
|
||||
|
||||
$query = Artwork::with('user:id,name,username')
|
||||
$query = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at');
|
||||
|
||||
@@ -106,7 +113,14 @@ final class ProfileApiController extends Controller
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with('user:id,name,username')
|
||||
$indexed = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->whereIn('id', $favIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
@@ -173,6 +187,9 @@ final class ProfileApiController extends Controller
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
@@ -183,6 +200,13 @@ final class ProfileApiController extends Controller
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
'content_type' => $contentType?->name,
|
||||
'content_type_slug' => $contentType?->slug,
|
||||
'category' => $category?->name,
|
||||
'category_slug' => $category?->slug,
|
||||
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
|
||||
'published_at' => $this->formatIsoDate($art->published_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -49,6 +49,18 @@ use Inertia\Inertia;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
private const PROFILE_TABS = [
|
||||
'posts',
|
||||
'artworks',
|
||||
'stories',
|
||||
'achievements',
|
||||
'collections',
|
||||
'about',
|
||||
'stats',
|
||||
'favourites',
|
||||
'activity',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly UsernameApprovalService $usernameApprovalService,
|
||||
@@ -84,7 +96,12 @@ class ProfileController extends Controller
|
||||
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
|
||||
}
|
||||
|
||||
return $this->renderProfilePage($request, $user);
|
||||
$tab = $this->normalizeProfileTab($request->query('tab'));
|
||||
if ($tab !== null) {
|
||||
return $this->redirectToProfileTab($request, (string) $user->username, $tab);
|
||||
}
|
||||
|
||||
return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, 'posts');
|
||||
}
|
||||
|
||||
public function showGalleryByUsername(Request $request, string $username)
|
||||
@@ -111,6 +128,45 @@ class ProfileController extends Controller
|
||||
return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true);
|
||||
}
|
||||
|
||||
public function showTabByUsername(Request $request, string $username, string $tab)
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
$normalizedTab = $this->normalizeProfileTab($tab);
|
||||
|
||||
if ($normalizedTab === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
$redirect = DB::table('username_redirects')
|
||||
->whereRaw('LOWER(old_username) = ?', [$normalized])
|
||||
->value('new_username');
|
||||
|
||||
if ($redirect) {
|
||||
return redirect()->route('profile.tab', [
|
||||
'username' => strtolower((string) $redirect),
|
||||
'tab' => $normalizedTab,
|
||||
], 301);
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($username !== strtolower((string) $user->username)) {
|
||||
return redirect()->route('profile.tab', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'tab' => $normalizedTab,
|
||||
], 301);
|
||||
}
|
||||
|
||||
if ($request->query->has('tab')) {
|
||||
return $this->redirectToProfileTab($request, (string) $user->username, $normalizedTab);
|
||||
}
|
||||
|
||||
return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, $normalizedTab);
|
||||
}
|
||||
|
||||
public function legacyById(Request $request, int $id, ?string $username = null)
|
||||
{
|
||||
$user = User::query()->findOrFail($id);
|
||||
@@ -836,7 +892,13 @@ class ProfileController extends Controller
|
||||
return Redirect::route('dashboard.profile')->with('status', 'password-updated');
|
||||
}
|
||||
|
||||
private function renderProfilePage(Request $request, User $user, string $component = 'Profile/ProfileShow', bool $galleryOnly = false)
|
||||
private function renderProfilePage(
|
||||
Request $request,
|
||||
User $user,
|
||||
string $component = 'Profile/ProfileShow',
|
||||
bool $galleryOnly = false,
|
||||
?string $initialTab = null,
|
||||
)
|
||||
{
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$viewer = Auth::user();
|
||||
@@ -1088,8 +1150,19 @@ class ProfileController extends Controller
|
||||
$usernameSlug = strtolower((string) ($user->username ?? ''));
|
||||
$canonical = url('/@' . $usernameSlug);
|
||||
$galleryUrl = url('/@' . $usernameSlug . '/gallery');
|
||||
$profileTabUrls = collect(self::PROFILE_TABS)
|
||||
->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)])
|
||||
->all();
|
||||
$achievementSummary = $this->achievements->summary((int) $user->id);
|
||||
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
|
||||
$resolvedInitialTab = $this->normalizeProfileTab($initialTab);
|
||||
$isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null;
|
||||
$activeProfileUrl = $resolvedInitialTab !== null
|
||||
? ($profileTabUrls[$resolvedInitialTab] ?? $canonical)
|
||||
: $canonical;
|
||||
$tabMetaLabel = $resolvedInitialTab !== null
|
||||
? ucfirst($resolvedInitialTab)
|
||||
: null;
|
||||
|
||||
return Inertia::render($component, [
|
||||
'user' => [
|
||||
@@ -1133,20 +1206,51 @@ class ProfileController extends Controller
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'auth' => $authData,
|
||||
'initialTab' => $resolvedInitialTab,
|
||||
'profileUrl' => $canonical,
|
||||
'galleryUrl' => $galleryUrl,
|
||||
'profileTabUrls' => $profileTabUrls,
|
||||
])->withViewData([
|
||||
'page_title' => $galleryOnly
|
||||
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'),
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $canonical,
|
||||
: ($isTabLanding
|
||||
? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase')),
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||
'page_meta_description' => $galleryOnly
|
||||
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.'),
|
||||
: ($isTabLanding
|
||||
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.')),
|
||||
'og_image' => $avatarUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeProfileTab(mixed $tab): ?string
|
||||
{
|
||||
if (! is_string($tab)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim($tab));
|
||||
|
||||
return in_array($normalized, self::PROFILE_TABS, true) ? $normalized : null;
|
||||
}
|
||||
|
||||
private function redirectToProfileTab(Request $request, string $username, string $tab): RedirectResponse
|
||||
{
|
||||
$baseUrl = url('/@' . strtolower($username) . '/' . $tab);
|
||||
|
||||
$query = $request->query();
|
||||
unset($query['tab']);
|
||||
|
||||
if ($query !== []) {
|
||||
$baseUrl .= '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
return redirect()->to($baseUrl, 301);
|
||||
}
|
||||
|
||||
private function resolveFavouriteTable(): ?string
|
||||
{
|
||||
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
||||
@@ -1164,6 +1268,9 @@ class ProfileController extends Controller
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
@@ -1178,6 +1285,13 @@ class ProfileController extends Controller
|
||||
'user_id' => $art->user_id,
|
||||
'author_level' => (int) ($art->user?->level ?? 1),
|
||||
'author_rank' => (string) ($art->user?->rank ?? 'Newbie'),
|
||||
'content_type' => $contentType?->name,
|
||||
'content_type_slug' => $contentType?->slug,
|
||||
'category' => $category?->name,
|
||||
'category_slug' => $category?->slug,
|
||||
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
];
|
||||
|
||||
@@ -8,8 +8,11 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -167,23 +170,38 @@ final class ArtworkPageController extends Controller
|
||||
|
||||
// Recursive helper to format a comment and its nested replies
|
||||
$formatComment = null;
|
||||
$formatComment = function(ArtworkComment $c) use (&$formatComment) {
|
||||
$formatComment = function (ArtworkComment $c) use (&$formatComment): array {
|
||||
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
$canPublishLinks = (int) ($user?->level ?? 1) > 1 && strtolower((string) ($user?->rank ?? 'Newbie')) !== 'newbie';
|
||||
$rawContent = (string) ($c->raw_content ?? $c->content ?? '');
|
||||
$renderedContent = $c->rendered_content;
|
||||
|
||||
if (! is_string($renderedContent) || trim($renderedContent) === '') {
|
||||
$renderedContent = $rawContent !== ''
|
||||
? ContentSanitizer::render($rawContent)
|
||||
: nl2br(e(strip_tags((string) ($c->content ?? ''))));
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'content' => html_entity_decode((string) $c->content, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'rendered_content' => $c->rendered_content,
|
||||
'created_at' => $c->created_at?->toIsoString(),
|
||||
'rendered_content' => ContentSanitizer::sanitizeRenderedHtml($renderedContent, $canPublishLinks),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'user' => [
|
||||
'id' => $c->user?->id,
|
||||
'name' => $c->user?->name,
|
||||
'username' => $c->user?->username,
|
||||
'display' => $c->user?->username ?? $c->user?->name ?? 'User',
|
||||
'profile_url' => $c->user?->username ? '/@' . $c->user->username : null,
|
||||
'avatar_url' => $c->user?->profile?->avatar_url,
|
||||
'id' => $userId,
|
||||
'name' => $user?->name,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
'replies' => $replies->map($formatComment)->values()->all(),
|
||||
];
|
||||
|
||||
@@ -17,4 +17,4 @@ class ManageConversationParticipantRequest extends FormRequest
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ class RenameConversationRequest extends FormRequest
|
||||
'title' => 'required|string|max:120',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ class StoreConversationRequest extends FormRequest
|
||||
'client_temp_id' => 'nullable|string|max:120',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ class StoreMessageRequest extends FormRequest
|
||||
'reply_to_message_id' => 'nullable|integer|exists:messages,id',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ class ToggleMessageReactionRequest extends FormRequest
|
||||
'reaction' => 'required|string|max:32',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ class UpdateMessageRequest extends FormRequest
|
||||
'body' => 'required|string|max:5000',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -100,6 +101,7 @@ class ArtworkResource extends JsonResource
|
||||
'slug' => (string) $this->slug,
|
||||
'title' => $decode($this->title),
|
||||
'description' => $decode($this->description),
|
||||
'description_html' => $this->renderDescriptionHtml(),
|
||||
'dimensions' => [
|
||||
'width' => (int) ($this->width ?? 0),
|
||||
'height' => (int) ($this->height ?? 0),
|
||||
@@ -123,6 +125,8 @@ class ArtworkResource extends JsonResource
|
||||
'username' => (string) ($this->user?->username ?? ''),
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
'level' => (int) ($this->user?->level ?? 1),
|
||||
'rank' => (string) ($this->user?->rank ?? 'Newbie'),
|
||||
'followers_count' => $followerCount,
|
||||
],
|
||||
'viewer' => [
|
||||
@@ -168,4 +172,27 @@ class ArtworkResource extends JsonResource
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
|
||||
private function renderDescriptionHtml(): string
|
||||
{
|
||||
$rawDescription = (string) ($this->description ?? '');
|
||||
|
||||
if (trim($rawDescription) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (! $this->authorCanPublishLinks()) {
|
||||
return nl2br(e(ContentSanitizer::stripToPlain($rawDescription)));
|
||||
}
|
||||
|
||||
return ContentSanitizer::render($rawDescription);
|
||||
}
|
||||
|
||||
private function authorCanPublishLinks(): bool
|
||||
{
|
||||
$level = (int) ($this->user?->level ?? 1);
|
||||
$rank = strtolower((string) ($this->user?->rank ?? 'Newbie'));
|
||||
|
||||
return $level > 1 && $rank !== 'newbie';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Jobs;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkEmbedding;
|
||||
use App\Services\Vision\ArtworkEmbeddingClient;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -41,7 +42,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
return [2, 10, 30];
|
||||
}
|
||||
|
||||
public function handle(ArtworkEmbeddingClient $client): void
|
||||
public function handle(ArtworkEmbeddingClient $client, ArtworkVisionImageUrl $imageUrlBuilder): void
|
||||
{
|
||||
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
||||
return;
|
||||
@@ -79,7 +80,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
$imageUrl = $this->buildImageUrl($sourceHash);
|
||||
$imageUrl = $imageUrlBuilder->fromHash($sourceHash, (string) ($artwork->thumb_ext ?: 'webp'));
|
||||
if ($imageUrl === null) {
|
||||
return;
|
||||
}
|
||||
@@ -134,21 +135,6 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
return array_map(static fn (float $value): float => $value / $norm, $vector);
|
||||
}
|
||||
|
||||
private function buildImageUrl(string $hash): ?string
|
||||
{
|
||||
$base = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$variant = (string) config('vision.image_variant', 'md');
|
||||
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
|
||||
$clean = str_pad($clean, 6, '0');
|
||||
$segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
|
||||
|
||||
return $base . '/img/' . implode('/', $segments) . '/' . $variant . '.webp';
|
||||
}
|
||||
|
||||
private function lockKey(int $artworkId, string $model, string $version): string
|
||||
{
|
||||
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
|
||||
|
||||
@@ -31,4 +31,4 @@ class MessageRead extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue
|
||||
'sharer_name' => $this->sharer->name,
|
||||
'sharer_username' => $this->sharer->username,
|
||||
'message' => $this->sharer->name . ' shared your artwork "' . $this->artwork->title . '"',
|
||||
'url' => "/@{$this->sharer->username}?tab=posts",
|
||||
'url' => "/@{$this->sharer->username}/posts",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class PostCommentedNotification extends Notification implements ShouldQueue
|
||||
'commenter_name' => $this->commenter->name,
|
||||
'commenter_username' => $this->commenter->username,
|
||||
'message' => "{$this->commenter->name} commented on your post",
|
||||
'url' => "/@{$this->post->user->username}?tab=posts",
|
||||
'url' => "/@{$this->post->user->username}/posts",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,4 +44,4 @@ class ConversationPolicy
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,4 @@ class MessagePolicy
|
||||
{
|
||||
return $message->sender_id === $user->id || $user->isAdmin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,44 @@ class AppServiceProvider extends ServiceProvider
|
||||
Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('messages-read', function (Request $request): array {
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(120)->by('messages:read:user:' . $userId),
|
||||
Limit::perMinute(240)->by('messages:read:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('messages-typing', function (Request $request): array {
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
$conversationId = (int) $request->route('conversation_id');
|
||||
|
||||
return [
|
||||
Limit::perMinute(90)->by('messages:typing:user:' . $userId . ':conv:' . $conversationId),
|
||||
Limit::perMinute(180)->by('messages:typing:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('messages-recovery', function (Request $request): array {
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
$conversationId = (int) $request->route('conversation_id');
|
||||
|
||||
return [
|
||||
Limit::perMinute(30)->by('messages:recovery:user:' . $userId . ':conv:' . $conversationId),
|
||||
Limit::perMinute(60)->by('messages:recovery:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('messages-presence', function (Request $request): array {
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(180)->by('messages:presence:user:' . $userId),
|
||||
Limit::perMinute(300)->by('messages:presence:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function configureDownloadRateLimiter(): void
|
||||
|
||||
34
app/Providers/HorizonServiceProvider.php
Normal file
34
app/Providers/HorizonServiceProvider.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||
|
||||
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Horizon gate.
|
||||
*
|
||||
* This gate determines who can access Horizon in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewHorizon', function ($user = null) {
|
||||
return app()->environment('local')
|
||||
|| (is_object($user) && method_exists($user, 'isAdmin') && $user->isAdmin());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,8 @@ class ArtworkService
|
||||
{
|
||||
$query = Artwork::where('user_id', $userId)
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user:id,name,username,level,rank',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
|
||||
@@ -72,6 +72,20 @@ class ContentSanitizer
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize previously rendered HTML for display-time policy changes.
|
||||
* This is useful when stored HTML predates current link attributes or
|
||||
* when display rules depend on the author rather than the raw content.
|
||||
*/
|
||||
public static function sanitizeRenderedHtml(?string $html, bool $allowLinks = true): string
|
||||
{
|
||||
if ($html === null || trim($html) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return static::sanitizeHtml($html, $allowLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ALL HTML from input, returning plain text with newlines preserved.
|
||||
*/
|
||||
@@ -190,7 +204,7 @@ class ContentSanitizer
|
||||
* Whitelist-based HTML sanitizer.
|
||||
* Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes.
|
||||
*/
|
||||
private static function sanitizeHtml(string $html): string
|
||||
private static function sanitizeHtml(string $html, bool $allowLinks = true): string
|
||||
{
|
||||
// Parse with DOMDocument
|
||||
$doc = new \DOMDocument('1.0', 'UTF-8');
|
||||
@@ -202,7 +216,7 @@ class ContentSanitizer
|
||||
);
|
||||
libxml_clear_errors();
|
||||
|
||||
static::cleanNode($doc->getElementsByTagName('body')->item(0));
|
||||
static::cleanNode($doc->getElementsByTagName('body')->item(0), $allowLinks);
|
||||
|
||||
// Serialize back, removing the wrapping html/body
|
||||
$body = $doc->getElementsByTagName('body')->item(0);
|
||||
@@ -218,13 +232,17 @@ class ContentSanitizer
|
||||
/**
|
||||
* Recursively clean a DOMNode — strip forbidden tags/attributes.
|
||||
*/
|
||||
private static function cleanNode(\DOMNode $node): void
|
||||
private static function cleanNode(\DOMNode $node, bool $allowLinks = true): void
|
||||
{
|
||||
$toRemove = [];
|
||||
$toUnwrap = [];
|
||||
|
||||
foreach ($node->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
if (! $child instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = strtolower($child->nodeName);
|
||||
|
||||
if (! in_array($tag, self::ALLOWED_TAGS, true)) {
|
||||
@@ -245,17 +263,22 @@ class ContentSanitizer
|
||||
|
||||
// Force external links to be safe
|
||||
if ($tag === 'a') {
|
||||
if (! $allowLinks) {
|
||||
$toUnwrap[] = $child;
|
||||
continue;
|
||||
}
|
||||
|
||||
$href = $child->getAttribute('href');
|
||||
if ($href && ! static::isSafeUrl($href)) {
|
||||
$toUnwrap[] = $child;
|
||||
continue;
|
||||
}
|
||||
$child->setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
$child->setAttribute('rel', 'noopener noreferrer nofollow ugc');
|
||||
$child->setAttribute('target', '_blank');
|
||||
}
|
||||
|
||||
// Recurse
|
||||
static::cleanNode($child);
|
||||
static::cleanNode($child, $allowLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Services/Messaging/ConversationDeltaService.php
Normal file
31
app/Services/Messaging/ConversationDeltaService.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ConversationDeltaService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessagingPayloadFactory $payloadFactory,
|
||||
) {}
|
||||
|
||||
public function messagesAfter(Conversation $conversation, User $viewer, int $afterMessageId, ?int $limit = null): Collection
|
||||
{
|
||||
$maxMessages = max(1, (int) config('messaging.recovery.max_messages', 100));
|
||||
$effectiveLimit = min($limit ?? $maxMessages, $maxMessages);
|
||||
|
||||
return Message::withTrashed()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->where('id', '>', $afterMessageId)
|
||||
->with(['sender:id,username,name', 'reactions', 'attachments'])
|
||||
->orderBy('id')
|
||||
->limit($effectiveLimit)
|
||||
->get()
|
||||
->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $viewer->id))
|
||||
->values();
|
||||
}
|
||||
}
|
||||
76
app/Services/Messaging/ConversationReadService.php
Normal file
76
app/Services/Messaging/ConversationReadService.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Events\ConversationUpdated;
|
||||
use App\Events\MessageRead;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ConversationReadService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConversationStateService $conversationState,
|
||||
) {}
|
||||
|
||||
public function markConversationRead(Conversation $conversation, User $user, ?int $messageId = null): ConversationParticipant
|
||||
{
|
||||
/** @var ConversationParticipant $participant */
|
||||
$participant = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->firstOrFail();
|
||||
|
||||
$lastReadableMessage = Message::query()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('sender_id', '!=', $user->id)
|
||||
->when($messageId, fn ($query) => $query->where('id', '<=', $messageId))
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$readAt = now();
|
||||
|
||||
$participant->forceFill([
|
||||
'last_read_at' => $readAt,
|
||||
'last_read_message_id' => $lastReadableMessage?->id,
|
||||
])->save();
|
||||
|
||||
if ($lastReadableMessage) {
|
||||
$messageReads = Message::query()
|
||||
->select(['id'])
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('sender_id', '!=', $user->id)
|
||||
->where('id', '<=', $lastReadableMessage->id)
|
||||
->get()
|
||||
->map(fn (Message $message) => [
|
||||
'message_id' => $message->id,
|
||||
'user_id' => $user->id,
|
||||
'read_at' => $readAt,
|
||||
])
|
||||
->all();
|
||||
|
||||
if (! empty($messageReads)) {
|
||||
DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']);
|
||||
}
|
||||
}
|
||||
|
||||
$participantIds = $this->conversationState->activeParticipantIds($conversation);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantIds);
|
||||
|
||||
DB::afterCommit(function () use ($conversation, $participant, $user, $participantIds): void {
|
||||
event(new MessageRead($conversation, $participant, $user));
|
||||
|
||||
foreach ($participantIds as $participantId) {
|
||||
event(new ConversationUpdated($participantId, $conversation, 'message.read'));
|
||||
}
|
||||
});
|
||||
|
||||
return $participant->fresh(['user']);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,9 @@
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Events\ConversationUpdated;
|
||||
use App\Events\MessageRead;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ConversationStateService
|
||||
{
|
||||
@@ -37,62 +32,4 @@ class ConversationStateService
|
||||
Cache::increment($versionKey);
|
||||
}
|
||||
}
|
||||
|
||||
public function markConversationRead(Conversation $conversation, User $user, ?int $messageId = null): ConversationParticipant
|
||||
{
|
||||
/** @var ConversationParticipant $participant */
|
||||
$participant = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->firstOrFail();
|
||||
|
||||
$lastReadableMessage = Message::query()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('sender_id', '!=', $user->id)
|
||||
->when($messageId, fn ($query) => $query->where('id', '<=', $messageId))
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$readAt = now();
|
||||
|
||||
$participant->update([
|
||||
'last_read_at' => $readAt,
|
||||
'last_read_message_id' => $lastReadableMessage?->id,
|
||||
]);
|
||||
|
||||
if ($lastReadableMessage) {
|
||||
$messageReads = Message::query()
|
||||
->select(['id'])
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('sender_id', '!=', $user->id)
|
||||
->where('id', '<=', $lastReadableMessage->id)
|
||||
->get()
|
||||
->map(fn (Message $message) => [
|
||||
'message_id' => $message->id,
|
||||
'user_id' => $user->id,
|
||||
'read_at' => $readAt,
|
||||
])
|
||||
->all();
|
||||
|
||||
if (! empty($messageReads)) {
|
||||
DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']);
|
||||
}
|
||||
}
|
||||
|
||||
$participantIds = $this->activeParticipantIds($conversation);
|
||||
$this->touchConversationCachesForUsers($participantIds);
|
||||
|
||||
DB::afterCommit(function () use ($conversation, $participant, $user): void {
|
||||
event(new MessageRead($conversation, $participant, $user));
|
||||
|
||||
foreach ($this->activeParticipantIds($conversation) as $participantId) {
|
||||
event(new ConversationUpdated($participantId, $conversation, 'message.read'));
|
||||
}
|
||||
});
|
||||
|
||||
return $participant->fresh(['user']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ use Illuminate\Support\Str;
|
||||
|
||||
class MessageNotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessagingPresenceService $presence,
|
||||
) {}
|
||||
|
||||
public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void
|
||||
{
|
||||
if (! DB::getSchemaBuilder()->hasTable('notifications')) {
|
||||
@@ -36,6 +40,13 @@ class MessageNotificationService
|
||||
->whereIn('id', $recipientIds)
|
||||
->get()
|
||||
->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender))
|
||||
->filter(function (User $recipient): bool {
|
||||
if (! (bool) config('messaging.notifications.offline_fallback_only', true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $this->presence->isUserOnline((int) $recipient->id);
|
||||
})
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->values()
|
||||
|
||||
@@ -56,7 +56,7 @@ class MessagingPayloadFactory
|
||||
'title' => $conversation->title,
|
||||
'is_active' => (bool) ($conversation->is_active ?? true),
|
||||
'last_message_at' => optional($conversation->last_message_at)?->toIso8601String(),
|
||||
'unread_count' => $conversation->unreadCountFor($viewerId),
|
||||
'unread_count' => app(UnreadCounterService::class)->unreadCountForConversation($conversation, $viewerId),
|
||||
'my_participant' => $myParticipant ? $this->participant($myParticipant) : null,
|
||||
'all_participants' => $conversation->allParticipants
|
||||
->whereNull('left_at')
|
||||
@@ -149,4 +149,4 @@ class MessagingPayloadFactory
|
||||
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
app/Services/Messaging/MessagingPresenceService.php
Normal file
69
app/Services/Messaging/MessagingPresenceService.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class MessagingPresenceService
|
||||
{
|
||||
public function touch(User|int $user, ?int $conversationId = null): void
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : (int) $user;
|
||||
$store = $this->store();
|
||||
$onlineKey = $this->onlineKey($userId);
|
||||
$existing = $store->get($onlineKey, []);
|
||||
$previousConversationId = (int) ($existing['conversation_id'] ?? 0) ?: null;
|
||||
$onlineTtl = max(30, (int) config('messaging.presence.ttl_seconds', 90));
|
||||
$conversationTtl = max(15, (int) config('messaging.presence.conversation_ttl_seconds', 45));
|
||||
|
||||
if ($previousConversationId && $previousConversationId !== $conversationId) {
|
||||
$store->forget($this->conversationKey($previousConversationId, $userId));
|
||||
}
|
||||
|
||||
$store->put($onlineKey, [
|
||||
'conversation_id' => $conversationId,
|
||||
'seen_at' => now()->toIso8601String(),
|
||||
], now()->addSeconds($onlineTtl));
|
||||
|
||||
if ($conversationId) {
|
||||
$store->put($this->conversationKey($conversationId, $userId), now()->toIso8601String(), now()->addSeconds($conversationTtl));
|
||||
}
|
||||
}
|
||||
|
||||
public function isUserOnline(int $userId): bool
|
||||
{
|
||||
return $this->store()->has($this->onlineKey($userId));
|
||||
}
|
||||
|
||||
public function isViewingConversation(int $conversationId, int $userId): bool
|
||||
{
|
||||
return $this->store()->has($this->conversationKey($conversationId, $userId));
|
||||
}
|
||||
|
||||
private function onlineKey(int $userId): string
|
||||
{
|
||||
return 'messages:presence:user:' . $userId;
|
||||
}
|
||||
|
||||
private function conversationKey(int $conversationId, int $userId): string
|
||||
{
|
||||
return 'messages:presence:conversation:' . $conversationId . ':user:' . $userId;
|
||||
}
|
||||
|
||||
private function store(): Repository
|
||||
{
|
||||
$store = (string) config('messaging.presence.cache_store', 'redis');
|
||||
|
||||
if ($store === 'redis' && ! class_exists('Redis')) {
|
||||
return Cache::store();
|
||||
}
|
||||
|
||||
try {
|
||||
return Cache::store($store);
|
||||
} catch (\Throwable) {
|
||||
return Cache::store();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,4 +123,4 @@ class SendMessageAction
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
81
app/Services/Messaging/UnreadCounterService.php
Normal file
81
app/Services/Messaging/UnreadCounterService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class UnreadCounterService
|
||||
{
|
||||
public function applyUnreadCountSelect(Builder $query, User|int $user, string $participantAlias = 'cp_me'): Builder
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : (int) $user;
|
||||
|
||||
return $query->addSelect([
|
||||
'unread_count' => Message::query()
|
||||
->selectRaw('count(*)')
|
||||
->whereColumn('messages.conversation_id', 'conversations.id')
|
||||
->where('messages.sender_id', '!=', $userId)
|
||||
->whereNull('messages.deleted_at')
|
||||
->where(function ($nested) use ($participantAlias) {
|
||||
$nested->where(function ($group) use ($participantAlias) {
|
||||
$group->whereNull($participantAlias . '.last_read_message_id')
|
||||
->whereNull($participantAlias . '.last_read_at');
|
||||
})->orWhereColumn('messages.id', '>', $participantAlias . '.last_read_message_id')
|
||||
->orWhereColumn('messages.created_at', '>', $participantAlias . '.last_read_at');
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unreadCountForConversation(Conversation $conversation, User|int $user): int
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : (int) $user;
|
||||
|
||||
$participant = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->where('user_id', $userId)
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if (! $participant) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->unreadCountForParticipant($participant);
|
||||
}
|
||||
|
||||
public function unreadCountForParticipant(ConversationParticipant $participant): int
|
||||
{
|
||||
$query = Message::query()
|
||||
->where('conversation_id', $participant->conversation_id)
|
||||
->where('sender_id', '!=', $participant->user_id)
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if ($participant->last_read_message_id) {
|
||||
$query->where('id', '>', $participant->last_read_message_id);
|
||||
} elseif ($participant->last_read_at) {
|
||||
$query->where('created_at', '>', $participant->last_read_at);
|
||||
}
|
||||
|
||||
return (int) $query->count();
|
||||
}
|
||||
|
||||
public function totalUnreadForUser(User|int $user): int
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : (int) $user;
|
||||
|
||||
return (int) Conversation::query()
|
||||
->select('conversations.id')
|
||||
->join('conversation_participants as cp_me', function ($join) use ($userId) {
|
||||
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
||||
->where('cp_me.user_id', '=', $userId)
|
||||
->whereNull('cp_me.left_at');
|
||||
})
|
||||
->where('conversations.is_active', true)
|
||||
->get()
|
||||
->sum(fn (Conversation $conversation) => $this->unreadCountForConversation($conversation, $userId));
|
||||
}
|
||||
}
|
||||
29
app/Services/Vision/ArtworkVisionImageUrl.php
Normal file
29
app/Services/Vision/ArtworkVisionImageUrl.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ThumbnailService;
|
||||
|
||||
final class ArtworkVisionImageUrl
|
||||
{
|
||||
public function fromArtwork(Artwork $artwork): ?string
|
||||
{
|
||||
return $this->fromHash(
|
||||
(string) ($artwork->hash ?? ''),
|
||||
(string) ($artwork->thumb_ext ?: 'webp')
|
||||
);
|
||||
}
|
||||
|
||||
public function fromHash(?string $hash, ?string $ext = 'webp', string $size = 'md'): ?string
|
||||
{
|
||||
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) $hash));
|
||||
if ($clean === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ThumbnailService::fromHash($clean, $ext, $size);
|
||||
}
|
||||
}
|
||||
213
app/Services/Vision/VectorGatewayClient.php
Normal file
213
app/Services/Vision/VectorGatewayClient.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class VectorGatewayClient
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return (bool) config('vision.vector_gateway.enabled', true)
|
||||
&& $this->baseUrl() !== ''
|
||||
&& $this->apiKey() !== '';
|
||||
}
|
||||
|
||||
public function upsertByUrl(string $imageUrl, int|string $id, array $metadata = []): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->url((string) config('vision.vector_gateway.upsert_endpoint', '/vectors/upsert')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
'id' => (string) $id,
|
||||
'metadata' => $metadata,
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector upsert', $response));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
public function searchByUrl(string $imageUrl, int $limit = 5): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector search', $response));
|
||||
}
|
||||
|
||||
return $this->extractMatches($response->json());
|
||||
}
|
||||
|
||||
public function deleteByIds(array $ids): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->url((string) config('vision.vector_gateway.delete_endpoint', '/vectors/delete')),
|
||||
[
|
||||
'ids' => array_values(array_map(static fn (int|string $id): string => (string) $id, $ids)),
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector delete', $response));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
private function request(): PendingRequest
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||
}
|
||||
|
||||
return Http::acceptJson()
|
||||
->withHeaders([
|
||||
'X-API-Key' => $this->apiKey(),
|
||||
])
|
||||
->connectTimeout(max(1, (int) config('vision.vector_gateway.connect_timeout_seconds', 5)))
|
||||
->timeout(max(1, (int) config('vision.vector_gateway.timeout_seconds', 20)))
|
||||
->retry(
|
||||
max(0, (int) config('vision.vector_gateway.retries', 1)),
|
||||
max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)),
|
||||
throw: false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function postJson(string $url, array $payload): Response
|
||||
{
|
||||
$response = $this->request()->post($url, $payload);
|
||||
|
||||
if (! $response instanceof Response) {
|
||||
throw new RuntimeException('Vector gateway request did not return an HTTP response.');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('vision.vector_gateway.base_url', ''), '/');
|
||||
}
|
||||
|
||||
private function apiKey(): string
|
||||
{
|
||||
return trim((string) config('vision.vector_gateway.api_key', ''));
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
return $this->baseUrl() . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function failureMessage(string $operation, Response $response): string
|
||||
{
|
||||
$body = trim($response->body());
|
||||
|
||||
if ($body === '') {
|
||||
return $operation . ' failed with HTTP ' . $response->status() . '.';
|
||||
}
|
||||
|
||||
return $operation . ' failed with HTTP ' . $response->status() . ': ' . $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $json
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
private function extractMatches(mixed $json): array
|
||||
{
|
||||
$candidates = [];
|
||||
|
||||
if (is_array($json)) {
|
||||
$candidates = $this->extractCandidateRows($json);
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
if (! is_array($candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $candidate['id']
|
||||
?? $candidate['point_id']
|
||||
?? $candidate['payload']['id']
|
||||
?? $candidate['metadata']['id']
|
||||
?? null;
|
||||
|
||||
if (! is_int($id) && ! is_string($id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = $candidate['score']
|
||||
?? $candidate['similarity']
|
||||
?? $candidate['distance']
|
||||
?? 0.0;
|
||||
|
||||
$metadata = $candidate['metadata'] ?? $candidate['payload'] ?? [];
|
||||
if (! is_array($metadata)) {
|
||||
$metadata = [];
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'id' => $id,
|
||||
'score' => (float) $score,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $json
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function extractCandidateRows(array $json): array
|
||||
{
|
||||
$keys = ['results', 'matches', 'points', 'data'];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (! isset($json[$key]) || ! is_array($json[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $json[$key];
|
||||
if (array_is_list($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
foreach (['results', 'matches', 'points', 'items'] as $nestedKey) {
|
||||
if (isset($value[$nestedKey]) && is_array($value[$nestedKey]) && array_is_list($value[$nestedKey])) {
|
||||
return $value[$nestedKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_is_list($json) ? $json : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user