feat: add tag discovery analytics and reporting

This commit is contained in:
2026-03-17 18:23:38 +01:00
parent b3fc889452
commit 2728644477
29 changed files with 2660 additions and 112 deletions

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\TagInteractionReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class TagInteractionReportController extends Controller
{
public function __construct(private readonly TagInteractionReportService $reportService) {}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'from' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'date_format:Y-m-d'],
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 15);
if ($from > $to) {
return response()->json([
'message' => 'Invalid date range: from must be before or equal to to.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$report = $this->reportService->buildReport($from, $to, $limit);
return response()->json([
'meta' => [
'from' => $from,
'to' => $to,
'limit' => $limit,
'generated_at' => now()->toISOString(),
'latest_aggregated_date' => $report['latest_aggregated_date'],
],
'overview' => $report['overview'],
'daily_clicks' => $report['daily_clicks'],
'by_surface' => $report['by_surface'],
'top_tags' => $report['top_tags'],
'top_queries' => $report['top_queries'],
'top_transitions' => $report['top_transitions'],
], Response::HTTP_OK);
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Recommendation\UserPreferenceBuilder;
use App\Services\Tags\TagDiscoveryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -30,7 +31,10 @@ final class SuggestedTagsController extends Controller
{
private const LIMIT = 20;
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
public function __construct(
private readonly UserPreferenceBuilder $prefBuilder,
private readonly TagDiscoveryService $tagDiscoveryService,
) {}
public function __invoke(Request $request): JsonResponse
{
@@ -126,27 +130,18 @@ final class SuggestedTagsController extends Controller
}
/**
* Aggregate tag usage over the last 7 days as a proxy for trend score.
* Uses artwork_tag + artworks.published_at to avoid a heavy events table dependency.
*
* @return \Illuminate\Support\Collection<int, object{slug:string, trend_score:float}>
*/
private function trendingTags(int $limit): \Illuminate\Support\Collection
{
$since = now()->subDays(7);
return DB::table('artwork_tag as at')
->join('tags as t', 't.id', '=', 'at.tag_id')
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
->where('a.published_at', '>=', $since)
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNull('a.deleted_at')
->where('t.is_active', true)
->selectRaw('t.slug, COUNT(*) / 1.0 as trend_score')
->groupBy('t.id', 't.slug')
->orderByDesc('trend_score')
->limit($limit)
->get();
return $this->tagDiscoveryService
->popularTags($limit, 7)
->map(static fn ($tag) => (object) [
'slug' => (string) $tag->slug,
'trend_score' => max(
(float) ($tag->recent_clicks ?? 0),
(float) ($tag->usage_count ?? 0) / 1000
),
]);
}
}

View File

@@ -7,33 +7,24 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tags\PopularTagsRequest;
use App\Http\Requests\Tags\TagSearchRequest;
use App\Models\Tag;
use App\Services\Tags\TagDiscoveryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
final class TagController extends Controller
{
public function __construct(private readonly TagDiscoveryService $tagDiscoveryService) {}
public function search(TagSearchRequest $request): JsonResponse
{
$q = trim((string) ($request->validated()['q'] ?? ''));
// Short results cached for 2 min; empty-query (popular suggestions) for 5 min.
$ttl = $q === '' ? 300 : 120;
$cacheKey = 'tags.search.' . ($q === '' ? '__empty__' : md5($q));
$cacheKey = 'tags.search.v2.' . ($q === '' ? '__empty__' : md5($q));
$data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed {
$query = Tag::query()->where('is_active', true);
if ($q !== '') {
$query->where(function ($sub) use ($q): void {
$sub->where('name', 'like', $q . '%')
->orWhere('slug', 'like', $q . '%');
});
}
return $query
->orderByDesc('usage_count')
->limit(20)
->get(['id', 'name', 'slug', 'usage_count']);
return $this->tagDiscoveryService->searchSuggestions($q, 20);
});
return response()->json(['data' => $data]);
@@ -42,14 +33,10 @@ final class TagController extends Controller
public function popular(PopularTagsRequest $request): JsonResponse
{
$limit = (int) ($request->validated()['limit'] ?? 20);
$cacheKey = 'tags.popular.' . $limit;
$cacheKey = 'tags.popular.v2.' . $limit;
$data = Cache::remember($cacheKey, 300, function () use ($limit): mixed {
return Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->limit($limit)
->get(['id', 'name', 'slug', 'usage_count']);
return $this->tagDiscoveryService->popularTags($limit);
});
return response()->json(['data' => $data]);

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class TagInteractionAnalyticsController extends Controller
{
public function store(Request $request): JsonResponse
{
$payload = $request->validate([
'event_type' => ['required', 'string', 'in:click'],
'surface' => ['required', 'string', 'in:search_suggestion,recent_search,rescue_suggestion,related_chip,related_cluster,top_companion'],
'tag_slug' => ['nullable', 'string', 'max:120'],
'source_tag_slug' => ['nullable', 'string', 'max:120'],
'query' => ['nullable', 'string', 'max:120'],
'position' => ['nullable', 'integer', 'min:1', 'max:50'],
'occurred_at' => ['nullable', 'date'],
'meta' => ['nullable', 'array'],
]);
if (($payload['surface'] ?? null) !== 'recent_search' && empty($payload['tag_slug'])) {
return response()->json([
'message' => 'The selected analytics surface requires a tag slug.',
'errors' => ['tag_slug' => ['The tag slug field is required for this surface.']],
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$occurredAt = isset($payload['occurred_at'])
? CarbonImmutable::parse((string) $payload['occurred_at'])
: CarbonImmutable::now();
$sessionId = $request->hasSession() ? (string) $request->session()->getId() : '';
$sessionKey = $sessionId !== '' ? hash('sha256', $sessionId) : null;
DB::table('tag_interaction_events')->insert([
'event_date' => $occurredAt->toDateString(),
'event_type' => (string) $payload['event_type'],
'surface' => (string) $payload['surface'],
'user_id' => $request->user()?->id,
'session_key' => $sessionKey,
'tag_slug' => isset($payload['tag_slug']) ? (string) $payload['tag_slug'] : null,
'source_tag_slug' => isset($payload['source_tag_slug']) ? (string) $payload['source_tag_slug'] : null,
'query' => isset($payload['query']) ? trim((string) $payload['query']) : null,
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
'meta' => $payload['meta'] ?? null,
'occurred_at' => $occurredAt,
'created_at' => now(),
'updated_at' => now(),
]);
return response()->json(['success' => true], Response::HTTP_OK);
}
}