feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -7,14 +7,16 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Services\ArtworkAwardService;
use App\Models\ArtworkMedal;
use App\Models\ArtworkMedalStat;
use App\Services\ArtworkMedalService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class ArtworkAwardController extends Controller
{
public function __construct(
private readonly ArtworkAwardService $service
private readonly ArtworkMedalService $service
) {}
/**
@@ -32,7 +34,7 @@ final class ArtworkAwardController extends Controller
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$award = $this->service->award($artwork, $user, $data['medal']);
$this->service->award($artwork, $user, $data['medal']);
// Record activity event
try {
@@ -51,6 +53,32 @@ final class ArtworkAwardController extends Controller
);
}
public function upsert(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$this->authorize('award', [ArtworkAward::class, $artwork]);
$data = $request->validate([
'medal_type' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$existed = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->exists();
$this->service->upsert($artwork, $user, $data['medal_type']);
return response()->json(
array_merge($this->buildPayload($artwork->id, $user->id), [
'message' => $existed ? 'Medal updated.' : 'Medal added.',
]),
$existed ? 200 : 201,
);
}
/**
* PUT /api/artworks/{id}/award
* Change an existing award medal.
@@ -60,7 +88,7 @@ final class ArtworkAwardController extends Controller
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->firstOrFail();
@@ -70,7 +98,7 @@ final class ArtworkAwardController extends Controller
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$award = $this->service->changeAward($artwork, $user, $data['medal']);
$this->service->changeMedal($artwork, $user, $data['medal']);
return response()->json($this->buildPayload($artwork->id, $user->id));
}
@@ -84,17 +112,29 @@ final class ArtworkAwardController extends Controller
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->firstOrFail();
$this->authorize('remove', $existingAward);
$this->service->removeAward($artwork, $user);
$this->service->removeMedal($artwork, $user);
return response()->json($this->buildPayload($artwork->id, $user->id));
}
public function destroyMedal(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$this->service->removeMedal($artwork, $user);
return response()->json(array_merge($this->buildPayload($artwork->id, $user->id), [
'message' => 'Medal removed.',
]));
}
/**
* GET /api/artworks/{id}/awards
* Return award stats + viewer's current award.
@@ -111,22 +151,29 @@ final class ArtworkAwardController extends Controller
private function buildPayload(int $artworkId, ?int $userId): array
{
$stat = \App\Models\ArtworkAwardStat::find($artworkId);
$stat = ArtworkMedalStat::find($artworkId);
$userAward = $userId
? ArtworkAward::where('artwork_id', $artworkId)
? ArtworkMedal::where('artwork_id', $artworkId)
->where('user_id', $userId)
->value('medal')
->value('medal_type')
: null;
$medals = [
'gold' => (int) ($stat?->gold_count ?? 0),
'silver' => (int) ($stat?->silver_count ?? 0),
'bronze' => (int) ($stat?->bronze_count ?? 0),
'score' => (int) ($stat?->score_total ?? 0),
'score_7d' => (int) ($stat?->score_7d ?? 0),
'score_30d' => (int) ($stat?->score_30d ?? 0),
'last_medaled_at' => $stat?->last_medaled_at?->toIsoString(),
];
return [
'awards' => [
'gold' => $stat?->gold_count ?? 0,
'silver' => $stat?->silver_count ?? 0,
'bronze' => $stat?->bronze_count ?? 0,
'score' => $stat?->score_total ?? 0,
],
'awards' => $medals,
'medals' => $medals,
'viewer_award' => $userAward,
'current_user_medal' => $userAward,
];
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\Profile\CreatorJourneyService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -31,7 +32,10 @@ use Illuminate\Support\Str;
*/
final class ArtworkDownloadController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __construct(
private readonly ArtworkStatsService $stats,
private readonly CreatorJourneyService $journeys,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
@@ -48,13 +52,15 @@ final class ArtworkDownloadController extends Controller
// Record the download event — non-blocking, errors are swallowed.
$this->recordDownload($request, $artwork);
// Increment counters — deferred via Redis when available.
// Increment counters immediately so Studio stats stay fresh.
try {
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: false);
} catch (\Throwable) {
// Stats failure must never interrupt the download.
}
$this->journeys->requestRebuild((int) $artwork->user_id);
// Resolve the highest-resolution download URL available.
$url = $this->resolveDownloadUrl($artwork);

View File

@@ -10,6 +10,7 @@ use App\Models\Artwork;
use App\Notifications\ArtworkLikedNotification;
use App\Services\FollowService;
use App\Services\Activity\UserActivityService;
use App\Services\ArtworkStatsService;
use App\Services\UserStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
@@ -168,7 +169,7 @@ final class ArtworkInteractionController extends Controller
public function share(Request $request, int $artworkId): JsonResponse
{
$data = $request->validate([
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed'],
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed,native'],
]);
if (Schema::hasTable('artwork_shares')) {
@@ -178,6 +179,8 @@ final class ArtworkInteractionController extends Controller
'platform' => $data['platform'],
'created_at' => now(),
]);
$this->syncArtworkStats($artworkId);
}
return response()->json(['ok' => true]);
@@ -216,25 +219,7 @@ final class ArtworkInteractionController extends Controller
private function syncArtworkStats(int $artworkId): void
{
if (! Schema::hasTable('artwork_stats')) {
return;
}
$favorites = Schema::hasTable('artwork_favourites')
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $artworkId],
[
'favorites' => $favorites,
'rating_count' => $likes,
]
);
app(ArtworkStatsService::class)->syncEngagementCounts($artworkId);
}
private function statusPayload(int $viewerId, int $artworkId): array

View File

@@ -16,14 +16,10 @@ use Illuminate\Http\Request;
*
* Fire-and-forget view tracker.
*
* Deduplication strategy (layered):
* 1. Session key (`art_viewed.{id}`) prevents double-counts within the
* same browser session (survives page reloads).
* 2. Route throttle (5 per 10 minutes per IP+artwork) catches bots that
* don't send session cookies.
*
* The frontend should additionally guard with sessionStorage so it only
* calls this endpoint once per page load.
* Every page visit should count as a new view.
* Lightweight abuse protection is handled at the route layer via throttling,
* while the stat increment itself is applied immediately so Studio analytics
* reflect new visits without waiting for the scheduler to flush Redis deltas.
*/
final class ArtworkViewController extends Controller
{
@@ -43,18 +39,11 @@ final class ArtworkViewController extends Controller
return response()->json(['error' => 'Not found'], 404);
}
$sessionKey = 'art_viewed.' . $id;
// Already counted this session — return early without touching the DB.
if ($request->hasSession() && $request->session()->has($sessionKey)) {
return response()->json(['ok' => true, 'counted' => false]);
}
// Write persistent event log (auth user_id or null for guests).
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
// Defer to Redis when available, fall back to direct DB increment.
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
// Apply the increment immediately so counters stay fresh in Studio.
$this->stats->incrementViews((int) $artwork->id, 1, defer: false);
$viewerId = $request->user()?->id;
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
@@ -66,11 +55,6 @@ final class ArtworkViewController extends Controller
);
}
// Mark this session so the artwork is not counted again.
if ($request->hasSession()) {
$request->session()->put($sessionKey, true);
}
return response()->json(['ok' => true, 'counted' => true]);
}
}

View File

@@ -39,6 +39,8 @@ final class ProfileApiController extends Controller
$query = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'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')
@@ -115,6 +117,8 @@ final class ProfileApiController extends Controller
$indexed = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'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')
@@ -190,6 +194,15 @@ final class ProfileApiController extends Controller
$category = $art->categories->first();
$contentType = $category?->contentType;
$stats = $art->stats;
$group = $art->group;
$isGroupPublisher = $group !== null;
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($art->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? null : ($art->user?->username ?? null);
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
$profileUrl = $isGroupPublisher
? $group->publicUrl()
: ($username ? '/@' . $username : null);
$publisherType = $isGroupPublisher ? 'group' : 'user';
return [
'id' => $art->id,
@@ -198,8 +211,22 @@ final class ProfileApiController extends Controller
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
'username' => $username,
'uname' => $displayName,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $publisherType,
'publisher' => [
'type' => $publisherType,
'id' => $isGroupPublisher ? (int) $group->id : (int) ($art->user?->id ?? 0),
'name' => $displayName,
'username' => $username ?? '',
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'user_id' => $art->user_id,
'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1),
'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'),
'content_type' => $contentType?->name,
'content_type_slug' => $contentType?->slug,
'category' => $category?->name,

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Profile\CreatorJourneyService;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
final class ProfileJourneyController extends Controller
{
public function __construct(private readonly CreatorJourneyService $journeys)
{
}
public function __invoke(string $username): JsonResponse
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()
->whereRaw('LOWER(username) = ?', [$normalized])
->where('is_active', true)
->whereNull('deleted_at')
->firstOrFail();
return response()->json([
'data' => $this->journeys->publicPayloadForUser($user),
'meta' => [
'username' => (string) $user->username,
'generated_at' => now()->toIso8601String(),
],
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\RankingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -26,7 +27,10 @@ use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
*/
class RankController extends Controller
{
public function __construct(private readonly RankingService $ranking) {}
public function __construct(
private readonly RankingService $ranking,
private readonly ContentTypeSlugResolver $contentTypeResolver,
) {}
/**
* GET /api/rank/global
@@ -65,7 +69,7 @@ class RankController extends Controller
{
$ct = is_numeric($contentType)
? ContentType::find((int) $contentType)
: ContentType::where('slug', $contentType)->first();
: $this->contentTypeResolver->resolve($contentType)->contentType;
if ($ct === null) {
return response()->json(['message' => 'Content type not found.'], 404);

View File

@@ -71,10 +71,10 @@ final class SuggestedCreatorsController extends Controller
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count,
COALESCE(us.uploads_count, 0) as artworks_count,
COUNT(*) as mutual_weight
')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
->orderByDesc('mutual_weight')
->limit(20)
->get();
@@ -117,10 +117,10 @@ final class SuggestedCreatorsController extends Controller
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count,
COALESCE(us.uploads_count, 0) as artworks_count,
COUNT(DISTINCT t.id) as matched_tags
')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.artworks_count')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
->orderByDesc('matched_tags')
->limit(20)
->get();
@@ -197,7 +197,7 @@ final class SuggestedCreatorsController extends Controller
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.artworks_count, 0) as artworks_count
COALESCE(us.uploads_count, 0) as artworks_count
')
->orderByDesc('followers_count')
->limit($limit)

View File

@@ -33,6 +33,7 @@ use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\PublishService;
use App\Services\Activity\UserActivityService;
use App\Services\ArtworkAttributionService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException;
@@ -558,7 +559,7 @@ final class UploadController extends Controller
], Response::HTTP_OK);
}
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution)
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
{
$user = $request->user();
@@ -566,7 +567,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
@@ -657,6 +658,7 @@ final class UploadController extends Controller
}
$artwork->save();
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
if ($mode === 'schedule' && $publishAt) {
@@ -760,7 +762,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],