Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz

This commit is contained in:
2026-03-22 09:13:39 +01:00
parent e8b5edf5d2
commit 2608be7420
80 changed files with 3991 additions and 723 deletions

View File

@@ -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 = [];

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -17,4 +17,4 @@ class ManageConversationParticipantRequest extends FormRequest
'user_id' => 'required|integer|exists:users,id',
];
}
}
}

View File

@@ -17,4 +17,4 @@ class RenameConversationRequest extends FormRequest
'title' => 'required|string|max:120',
];
}
}
}

View File

@@ -23,4 +23,4 @@ class StoreConversationRequest extends FormRequest
'client_temp_id' => 'nullable|string|max:120',
];
}
}
}

View File

@@ -21,4 +21,4 @@ class StoreMessageRequest extends FormRequest
'reply_to_message_id' => 'nullable|integer|exists:messages,id',
];
}
}
}

View File

@@ -17,4 +17,4 @@ class ToggleMessageReactionRequest extends FormRequest
'reaction' => 'required|string|max:32',
];
}
}
}

View File

@@ -17,4 +17,4 @@ class UpdateMessageRequest extends FormRequest
'body' => 'required|string|max:5000',
];
}
}
}

View File

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