storing analytics data
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
96
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal file
96
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
62
app/Http/Controllers/Api/ArtworkViewController.php
Normal file
62
app/Http/Controllers/Api/ArtworkViewController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
121
app/Http/Controllers/Api/SimilarArtworksController.php
Normal file
121
app/Http/Controllers/Api/SimilarArtworksController.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user