storing analytics data

This commit is contained in:
2026-02-27 09:46:51 +01:00
parent 15b7b77d20
commit f0cca76eb3
57 changed files with 3478 additions and 466 deletions

View File

@@ -34,6 +34,17 @@ final class ArtworkAwardController extends Controller
$award = $this->service->award($artwork, $user, $data['medal']);
// Record activity event
try {
\App\Models\ActivityEvent::record(
actorId: $user->id,
type: \App\Models\ActivityEvent::TYPE_AWARD,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
meta: ['medal' => $data['medal']],
);
} catch (\Throwable) {}
return response()->json(
$this->buildPayload($artwork->id, $user->id),
201

View File

@@ -93,6 +93,16 @@ class ArtworkCommentController extends Controller
$comment->load(['user', 'user.profile']);
// Record activity event (fire-and-forget; never break the response)
try {
\App\Models\ActivityEvent::record(
actorId: $request->user()->id,
type: \App\Models\ActivityEvent::TYPE_COMMENT,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
);
} catch (\Throwable) {}
return response()->json(['data' => $this->formatComment($comment, $request->user()->id)], 201);
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* POST /api/art/{id}/download
*
* Records a download event and returns the full-resolution download URL.
*
* Responsibilities:
* 1. Validates the artwork is public and published.
* 2. Inserts a row in artwork_downloads (artwork_id, user_id, ip, user_agent).
* 3. Increments artwork_stats.downloads + forwards to creator stats.
* 4. Returns {"ok": true, "url": "<download_url>"} so the frontend can
* trigger the actual browser download.
*
* The frontend fires this POST on click, then uses the returned URL to
* trigger the file download (or falls back to the pre-resolved URL it
* already has).
*/
final class ArtworkDownloadController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->with(['user:id'])
->where('id', $id)
->first();
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
// Record the download event — non-blocking, errors are swallowed.
$this->recordDownload($request, $artwork);
// Increment counters — deferred via Redis when available.
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
// Resolve the highest-resolution download URL available.
$url = $this->resolveDownloadUrl($artwork);
return response()->json(['ok' => true, 'url' => $url]);
}
/**
* Insert a row in artwork_downloads.
* Uses a raw insert for the binary(16) IP column.
* Silently ignores failures (analytics should never break user flow).
*/
private function recordDownload(Request $request, Artwork $artwork): void
{
try {
$ip = $request->ip() ?? '0.0.0.0';
$bin = @inet_pton($ip);
DB::table('artwork_downloads')->insert([
'artwork_id' => $artwork->id,
'user_id' => $request->user()?->id,
'ip' => $bin !== false ? $bin : null,
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
'created_at' => now(),
]);
} catch (\Throwable) {
// Analytics failure must never interrupt the download.
}
}
/**
* Resolve the best available download URL: XL LG MD.
* Returns an empty string if no thumbnail can be resolved.
*/
private function resolveDownloadUrl(Artwork $artwork): string
{
foreach (['xl', 'lg', 'md'] as $size) {
$thumb = ThumbnailPresenter::present($artwork, $size);
if (! empty($thumb['url'])) {
return (string) $thumb['url'];
}
}
return '';
}
}

View File

@@ -36,6 +36,16 @@ final class ArtworkInteractionController extends Controller
if ($state) {
$svc->incrementFavoritesReceived($creatorId);
$svc->setLastActiveAt((int) $request->user()->id);
// Record activity event (new favourite only)
try {
\App\Models\ActivityEvent::record(
actorId: (int) $request->user()->id,
type: \App\Models\ActivityEvent::TYPE_FAVORITE,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artworkId,
);
} catch (\Throwable) {}
} else {
$svc->decrementFavoritesReceived($creatorId);
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* POST /api/art/{id}/view
*
* 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.
*/
final class ArtworkViewController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->where('id', $id)
->first();
if (! $artwork) {
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);
// 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

@@ -18,6 +18,7 @@ class MessagingSettingsController extends Controller
{
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
'realtime_enabled' => (bool) config('messaging.realtime', false),
]);
}
@@ -31,6 +32,7 @@ class MessagingSettingsController extends Controller
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from,
'realtime_enabled' => (bool) config('messaging.realtime', false),
]);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
/**
* GET /api/art/{id}/similar
*
* Returns up to 12 similar artworks based on:
* 1. Tag overlap (primary signal)
* 2. Same category
* 3. Similar orientation
*
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
* Current artwork and its creator are excluded from results.
*/
final class SimilarArtworksController extends Controller
{
private const LIMIT = 12;
private const CACHE_TTL = 300; // 5 minutes
public function __construct(private readonly ArtworkSearchService $search) {}
public function __invoke(int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->with(['tags:id,slug', 'categories:id,slug'])
->find($id);
if (! $artwork) {
return response()->json(['error' => 'Artwork not found'], 404);
}
$cacheKey = "api.similar.{$artwork->id}";
$items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) {
return $this->findSimilar($artwork);
});
return response()->json(['data' => $items]);
}
private function findSimilar(Artwork $artwork): array
{
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
$orientation = $this->orientation($artwork);
// Build Meilisearch filter: exclude self and same creator
$filterParts = [
'is_public = true',
'is_approved = true',
'id != ' . $artwork->id,
'author_id != ' . $artwork->user_id,
];
// Filter by same orientation (landscape/portrait) — improves visual coherence
if ($orientation !== 'square') {
$filterParts[] = 'orientation = "' . $orientation . '"';
}
// Priority 1: tag overlap (OR match across tags)
if ($tagSlugs !== []) {
$tagFilter = implode(' OR ', array_map(
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
$tagSlugs
));
$filterParts[] = '(' . $tagFilter . ')';
} elseif ($categorySlugs !== []) {
// Fallback to category if no tags
$catFilter = implode(' OR ', array_map(
fn (string $c): string => 'category = "' . addslashes($c) . '"',
$categorySlugs
));
$filterParts[] = '(' . $catFilter . ')';
}
$results = Artwork::search('')
->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'likes:desc'],
])
->paginate(self::LIMIT);
return $results->getCollection()
->map(fn (Artwork $a): array => [
'id' => $a->id,
'title' => $a->title,
'slug' => $a->slug,
'thumb' => $a->thumbUrl('md'),
'url' => '/art/' . $a->id . '/' . $a->slug,
'author_id' => $a->user_id,
'orientation' => $this->orientation($a),
'width' => $a->width,
'height' => $a->height,
])
->values()
->all();
}
private function orientation(Artwork $artwork): string
{
if (! $artwork->width || ! $artwork->height) {
return 'square';
}
return match (true) {
$artwork->width > $artwork->height => 'landscape',
$artwork->height > $artwork->width => 'portrait',
default => 'square',
};
}
}

View File

@@ -518,6 +518,16 @@ final class UploadController extends Controller
$artwork->published_at = now();
$artwork->save();
// Record upload activity event
try {
\App\Models\ActivityEvent::record(
actorId: (int) $user->id,
type: \App\Models\ActivityEvent::TYPE_UPLOAD,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id,
);
} catch (\Throwable) {}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\ActivityEvent;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Community activity feed.
*
* GET /community/activity?type=global|following
*/
final class CommunityActivityController extends Controller
{
private const PER_PAGE = 30;
public function index(Request $request)
{
$user = $request->user();
$type = $request->query('type', 'global'); // global | following
$perPage = self::PER_PAGE;
$query = ActivityEvent::query()
->orderByDesc('created_at')
->with(['actor:id,name,username']);
if ($type === 'following' && $user) {
// Show only events from followed users
$followingIds = DB::table('user_followers')
->where('follower_id', $user->id)
->pluck('user_id')
->all();
if (empty($followingIds)) {
$query->whereRaw('0 = 1'); // empty result set
} else {
$query->whereIn('actor_id', $followingIds);
}
}
$events = $query->paginate($perPage)->withQueryString();
$enriched = $this->enrich($events->getCollection());
return view('web.community.activity', [
'events' => $events,
'enriched' => $enriched,
'active_tab' => $type,
'page_title' => 'Community Activity',
]);
}
/**
* Attach target object data to each event for display.
*/
private function enrich(\Illuminate\Support\Collection $events): \Illuminate\Support\Collection
{
// Collect artwork IDs and user IDs to eager-load
$artworkIds = $events
->where('target_type', ActivityEvent::TARGET_ARTWORK)
->pluck('target_id')
->unique()
->values()
->all();
$userIds = $events
->where('target_type', ActivityEvent::TARGET_USER)
->pluck('target_id')
->unique()
->values()
->all();
$artworks = Artwork::whereIn('id', $artworkIds)
->with('user:id,name,username')
->get(['id', 'title', 'slug', 'user_id', 'hash', 'thumb_ext'])
->keyBy('id');
$users = User::whereIn('id', $userIds)
->with('profile:user_id,avatar_hash')
->get(['id', 'name', 'username'])
->keyBy('id');
return $events->map(function (ActivityEvent $event) use ($artworks, $users): array {
$target = null;
if ($event->target_type === ActivityEvent::TARGET_ARTWORK) {
$artwork = $artworks->get($event->target_id);
$target = $artwork ? [
'id' => $artwork->id,
'title' => $artwork->title,
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
'thumb' => $artwork->thumbUrl('sm'),
] : null;
} elseif ($event->target_type === ActivityEvent::TARGET_USER) {
$u = $users->get($event->target_id);
$target = $u ? [
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'url' => '/@' . $u->username,
] : null;
}
return [
'id' => $event->id,
'type' => $event->type,
'target_type' => $event->target_type,
'actor' => [
'id' => $event->actor?->id,
'name' => $event->actor?->name,
'username' => $event->actor?->username,
'url' => '/@' . $event->actor?->username,
],
'target' => $target,
'created_at' => $event->created_at?->toIso8601String(),
];
});
}
}

View File

@@ -8,6 +8,7 @@ use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -190,26 +191,56 @@ final class DiscoverController extends Controller
->pluck('user_id');
if ($followingIds->isEmpty()) {
$artworks = Artwork::query()->paginate(0);
// Trending fallback: show popular artworks so the page isn't blank
try {
$fallbackResults = $this->searchService->discoverTrending(12);
$fallbackArtworks = $fallbackResults->getCollection()
->transform(fn ($a) => $this->presentArtwork($a));
} catch (\Throwable) {
$fallbackArtworks = collect();
}
// Suggested creators: most-followed users the viewer doesn't follow yet
$suggestedCreators = DB::table('users')
->join('user_statistics', 'users.id', '=', 'user_statistics.user_id')
->where('users.id', '!=', $user->id)
->whereNotNull('users.email_verified_at')
->where('users.is_active', true)
->orderByDesc('user_statistics.followers_count')
->limit(8)
->select(
'users.id',
'users.name',
'users.username',
'user_statistics.followers_count',
)
->get();
return view('web.discover.index', [
'artworks' => $artworks,
'page_title' => 'Following Feed',
'section' => 'following',
'description' => 'Follow some creators to see their work here.',
'icon' => 'fa-user-group',
'empty' => true,
'artworks' => collect(),
'page_title' => 'Following Feed',
'section' => 'following',
'description' => 'Follow some creators to see their work here.',
'icon' => 'fa-user-group',
'empty' => true,
'fallback_trending' => $fallbackArtworks,
'fallback_creators' => $suggestedCreators,
]);
}
$artworks = Artwork::query()
->public()
->published()
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->paginate($perPage)
->withQueryString();
$page = (int) request()->get('page', 1);
$cacheKey = "discover.following.{$user->id}.p{$page}";
$artworks = Cache::remember($cacheKey, 60, function () use ($user, $followingIds, $perPage): \Illuminate\Pagination\LengthAwarePaginator {
return Artwork::query()
->public()
->published()
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->paginate($perPage)
->withQueryString();
});
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));

View File

@@ -14,7 +14,10 @@ final class HomeController extends Controller
public function index(Request $request): \Illuminate\View\View
{
$sections = $this->homepage->all();
$user = $request->user();
$sections = $user
? $this->homepage->allForUser($user)
: $this->homepage->all();
$hero = $sections['hero'];
@@ -27,8 +30,9 @@ final class HomeController extends Controller
];
return view('web.home', [
'meta' => $meta,
'props' => $sections,
'meta' => $meta,
'props' => $sections,
'is_logged_in' => (bool) $user,
]);
}
}