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,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\TagInteractionReportService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
final class TagInteractionReportController extends Controller
{
public function __construct(private readonly TagInteractionReportService $reportService) {}
public function index(Request $request): View
{
$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);
abort_if($from > $to, 422, 'Invalid date range.');
$report = $this->reportService->buildReport($from, $to, $limit);
return view('admin.reports.tags', [
'filters' => [
'from' => $from,
'to' => $to,
'limit' => $limit,
],
'overview' => $report['overview'],
'dailyClicks' => $report['daily_clicks'],
'bySurface' => $report['by_surface'],
'topTags' => $report['top_tags'],
'topQueries' => $report['top_queries'],
'topTransitions' => $report['top_transitions'],
'latestAggregatedDate' => $report['latest_aggregated_date'],
]);
}
}

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);
}
}

View File

@@ -10,6 +10,7 @@ use App\Services\ArtworkSearchIndexer;
use App\Services\ArtworkVersioningService;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use App\Services\Tags\TagDiscoveryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -27,6 +28,7 @@ final class StudioArtworksApiController extends Controller
private readonly StudioBulkActionService $bulkService,
private readonly ArtworkVersioningService $versioningService,
private readonly ArtworkSearchIndexer $searchIndexer,
private readonly TagDiscoveryService $tagDiscoveryService,
) {}
/**
@@ -264,18 +266,13 @@ final class StudioArtworksApiController extends Controller
/**
* GET /api/studio/tags/search?q=...
* Search active tags by name for the bulk tag picker.
* Search active tags for studio pickers, with empty-query fallback to popular tags.
*/
public function searchTags(Request $request): JsonResponse
{
$query = trim((string) $request->input('q'));
$tags = \App\Models\Tag::query()
->where('is_active', true)
->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%"))
->orderByDesc('usage_count')
->limit(30)
->get(['id', 'name', 'slug', 'usage_count']);
$tags = $this->tagDiscoveryService->searchSuggestions($query, 30);
return response()->json($tags);
}