feat: ship creator journey v2 and profile updates
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
app/Http/Controllers/Api/ProfileJourneyController.php
Normal file
37
app/Http/Controllers/Api/ProfileJourneyController.php
Normal 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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user