592 lines
21 KiB
Markdown
592 lines
21 KiB
Markdown
# Discovery & Personalization Engine
|
||
|
||
Covers the trending system, following feed, personalized homepage, similar artworks, unified activity feed, and all input signal collection that powers the ranking formula.
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Architecture Overview](#1-architecture-overview)
|
||
2. [Input Signal Collection](#2-input-signal-collection)
|
||
3. [Windowed Stats (views & downloads)](#3-windowed-stats-views--downloads)
|
||
4. [Trending Engine](#4-trending-engine)
|
||
5. [Discover Routes](#5-discover-routes)
|
||
6. [Following Feed](#6-following-feed)
|
||
7. [Personalized Homepage](#7-personalized-homepage)
|
||
8. [Similar Artworks API](#8-similar-artworks-api)
|
||
9. [Unified Activity Feed](#9-unified-activity-feed)
|
||
10. [Meilisearch Configuration](#10-meilisearch-configuration)
|
||
11. [Caching Strategy](#11-caching-strategy)
|
||
12. [Scheduled Jobs](#12-scheduled-jobs)
|
||
13. [Testing](#13-testing)
|
||
14. [Operational Runbook](#14-operational-runbook)
|
||
|
||
---
|
||
|
||
## 1. Architecture Overview
|
||
|
||
```
|
||
Browser
|
||
│
|
||
├─ POST /api/art/{id}/view → ArtworkViewController
|
||
├─ POST /api/art/{id}/download → ArtworkDownloadController
|
||
└─ POST /api/artworks/{id}/favorite / reactions / awards / comments
|
||
│
|
||
▼
|
||
ArtworkStatsService UserStatsService
|
||
artwork_stats (all-time + user_statistics
|
||
windowed counters) └─ artwork_views_received_count
|
||
artwork_downloads (log) downloads_received_count
|
||
│
|
||
▼
|
||
skinbase:reset-windowed-stats (nightly/weekly)
|
||
└─ zeros views_24h / views_7d
|
||
└─ recomputes downloads_24h / downloads_7d from log
|
||
│
|
||
▼
|
||
skinbase:recalculate-trending (every 30 min)
|
||
└─ bulk UPDATE artworks.trending_score_24h / _7d
|
||
└─ dispatches IndexArtworkJob → Meilisearch
|
||
│
|
||
▼
|
||
Meilisearch index (artworks)
|
||
└─ sortable: trending_score_7d, trending_score_24h, views, ...
|
||
└─ filterable: author_id, tags, category, orientation, is_public, ...
|
||
│
|
||
▼
|
||
HomepageService / DiscoverController / SimilarArtworksController
|
||
└─ Redis cache (5 min TTL)
|
||
│
|
||
▼
|
||
Inertia + React frontend
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Input Signal Collection
|
||
|
||
### 2.1 View tracking — `POST /api/art/{id}/view`
|
||
|
||
**Controller:** `App\Http\Controllers\Api\ArtworkViewController`
|
||
**Route name:** `api.art.view`
|
||
**Throttle:** 5 requests per 10 minutes per IP
|
||
|
||
**Deduplication (layered):**
|
||
|
||
| Layer | Mechanism | Scope |
|
||
|---|---|---|
|
||
| Client-side | `sessionStorage` key `sb_viewed_{id}` set before the request | Browser tab lifetime |
|
||
| Server-side | `$request->session()->put('art_viewed.{id}', true)` | Laravel session lifetime |
|
||
| Throttle | `throttle:5,10` route middleware | Per-IP per-artwork |
|
||
|
||
The React component `ArtworkActions.jsx` fires a `useEffect` on mount that checks `sessionStorage` first, then hits the endpoint. The response includes `counted: true|false` so callers can confirm whether the increment actually happened.
|
||
|
||
**What gets incremented:**
|
||
|
||
```
|
||
artwork_stats.views +1 (all-time)
|
||
artwork_stats.views_24h +1 (zeroed nightly)
|
||
artwork_stats.views_7d +1 (zeroed weekly)
|
||
user_statistics.artwork_views_received_count +1 (creator aggregate)
|
||
```
|
||
|
||
Via `ArtworkStatsService::incrementViews()` with `defer: true` (Redis when available, direct DB fallback).
|
||
|
||
---
|
||
|
||
### 2.2 Download tracking — `POST /api/art/{id}/download`
|
||
|
||
**Controller:** `App\Http\Controllers\Api\ArtworkDownloadController`
|
||
**Route name:** `api.art.download`
|
||
**Throttle:** 10 requests per minute per IP
|
||
|
||
The endpoint:
|
||
1. Inserts a row in `artwork_downloads` (persistent event log with `created_at`)
|
||
2. Increments `artwork_stats.downloads`, `downloads_24h`, `downloads_7d`
|
||
3. Returns `{"ok": true, "url": "<highest-res thumbnail URL>"}` for the native browser download
|
||
|
||
The `<a download>` buttons in `ArtworkActions.jsx` call `trackDownload()` on click — a fire-and-forget `fetch()` POST. The actual browser download is triggered by the `href`/`download` attributes and is never blocked by the tracking request.
|
||
|
||
**What gets incremented:**
|
||
|
||
```
|
||
artwork_downloads INSERT (event log, persisted forever)
|
||
artwork_stats.downloads +1 (all-time)
|
||
artwork_stats.downloads_24h +1 (recomputed from log nightly)
|
||
artwork_stats.downloads_7d +1 (recomputed from log weekly)
|
||
user_statistics.downloads_received_count +1 (creator aggregate)
|
||
```
|
||
|
||
Via `ArtworkStatsService::incrementDownloads()` with `defer: true`.
|
||
|
||
---
|
||
|
||
### 2.3 Other signals (already existed)
|
||
|
||
| Signal | Endpoint / Service | Written to |
|
||
|---|---|---|
|
||
| Favorite toggle | `POST /api/artworks/{id}/favorite` | `user_favorites`, `artwork_stats.favorites` |
|
||
| Reaction toggle | `POST /api/artworks/{id}/reactions` | `artwork_reactions` |
|
||
| Award | `ArtworkAwardController` | `artwork_award_stats.score_total` |
|
||
| Comment | `ArtworkCommentController` | `artwork_comments`, `activity_events` |
|
||
| Follow | `FollowService` | `user_followers`, `activity_events` |
|
||
|
||
---
|
||
|
||
### 2.4 ArtworkStatsService — Redis deferral
|
||
|
||
When Redis is available all increments are pushed to a list key `artwork_stats:deltas` as JSON payloads. A separate job/command (`processPendingFromRedis`) drains the queue and applies bulk `applyDelta()` calls. If Redis is unavailable the service falls back transparently to a direct DB increment.
|
||
|
||
```php
|
||
// Deferred (default for view/download controllers)
|
||
$svc->incrementViews($artworkId, 1, defer: true);
|
||
|
||
// Immediate (e.g. favorites toggle needs instant feedback)
|
||
$svc->incrementDownloads($artworkId, 1, defer: false);
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Windowed Stats (views & downloads)
|
||
|
||
### 3.1 Why windowed columns?
|
||
|
||
The trending formula needs _recent_ activity, not all-time totals. `artwork_stats.views` is a monotonically increasing counter — using it for trending would permanently favour old popular artworks and new artworks could never compete.
|
||
|
||
The solution is four cached window columns refreshed on a schedule:
|
||
|
||
| Column | Meaning | Reset cadence |
|
||
|---|---|---|
|
||
| `views_24h` | Views since last midnight reset | Nightly at 03:30 |
|
||
| `views_7d` | Views since last Monday reset | Weekly (Mon) at 03:30 |
|
||
| `downloads_24h` | Downloads in last 24 h | Nightly at 03:30 (recomputed from log) |
|
||
| `downloads_7d` | Downloads in last 7 days | Weekly (Mon) at 03:30 (recomputed from log) |
|
||
|
||
### 3.2 How views windowing works
|
||
|
||
**No per-view event log exists** (storing millions of view rows would be expensive). Instead:
|
||
|
||
- Every view event increments `views_24h` and `views_7d` alongside `views`.
|
||
- The reset command **zeroes** both columns. Artworks re-accumulate from the reset time onward.
|
||
- Accuracy is "views since last reset", which is close enough for trending (error ≤ 1 day).
|
||
|
||
### 3.3 How downloads windowing works
|
||
|
||
**`artwork_downloads` is a full event log** with `created_at`. The reset command:
|
||
|
||
1. Queries `COUNT(*) FROM artwork_downloads WHERE artwork_id = ? AND created_at >= NOW() - {interval}` for each artwork in chunks of 1000.
|
||
2. Writes the exact count back to `downloads_24h` / `downloads_7d`.
|
||
|
||
This overwrites any drift from deferred Redis increments, making download windows always accurate at reset time.
|
||
|
||
### 3.4 Reset command
|
||
|
||
```bash
|
||
php artisan skinbase:reset-windowed-stats --period=24h
|
||
php artisan skinbase:reset-windowed-stats --period=7d
|
||
```
|
||
|
||
Uses chunked PHP loop (no `GREATEST()` / `INTERVAL` MySQL syntax) → works in both production MySQL and SQLite test DB.
|
||
|
||
---
|
||
|
||
## 4. Trending Engine
|
||
|
||
### 4.1 Formula
|
||
|
||
```
|
||
score = (award_score × 5.0)
|
||
+ (favorites × 3.0)
|
||
+ (reactions × 2.0)
|
||
+ (downloads_Xd × 1.0) ← windowed: 24h or 7d
|
||
+ (views_Xd × 2.0) ← windowed: 24h or 7d
|
||
- (hours_since_published × 0.1)
|
||
|
||
score = max(score, 0) ← clamped via GREATEST()
|
||
```
|
||
|
||
Weights are constants in `TrendingService` (`W_AWARD`, `W_FAVORITE`, etc.) — adjust without a schema change.
|
||
|
||
### 4.2 Output columns
|
||
|
||
| Artworks column | Meaning |
|
||
|---|---|
|
||
| `trending_score_24h` | Score using `views_24h` + `downloads_24h`; targets artworks ≤ 7 days old |
|
||
| `trending_score_7d` | Score using `views_7d` + `downloads_7d`; targets artworks ≤ 30 days old |
|
||
| `last_trending_calculated_at` | Timestamp of last calculation |
|
||
|
||
### 4.3 Recalculation command
|
||
|
||
```bash
|
||
php artisan skinbase:recalculate-trending --period=24h
|
||
php artisan skinbase:recalculate-trending --period=7d
|
||
php artisan skinbase:recalculate-trending --period=all
|
||
php artisan skinbase:recalculate-trending --period=7d --skip-index # skip Meilisearch jobs
|
||
php artisan skinbase:recalculate-trending --chunk=500 # smaller DB chunks
|
||
```
|
||
|
||
**Implementation:** `App\Services\TrendingService::recalculate()`
|
||
|
||
1. Chunks artworks published within the look-back window (`chunkById(1000, ...)`).
|
||
2. Issues one bulk MySQL `UPDATE ... WHERE id IN (...)` per chunk — no per-artwork queries in the hot path.
|
||
3. After each chunk, dispatches `IndexArtworkJob` per artwork to push updated scores to Meilisearch (skippable with `--skip-index`).
|
||
|
||
> **Note:** The raw SQL uses `GREATEST()` and `TIMESTAMPDIFF(HOUR, ...)` which are MySQL 8 only. The command is tested in production against MySQL; the 4 related Pest tests are skipped on SQLite with a clear skip message.
|
||
|
||
### 4.4 Meilisearch sync after calculation
|
||
|
||
`TrendingService::syncToSearchIndex()` dispatches `IndexArtworkJob` for every artwork in the trending window. The job calls `Artwork::searchable()` which triggers `toSearchableArray()`, which includes `trending_score_24h` and `trending_score_7d`.
|
||
|
||
---
|
||
|
||
## 5. Discover Routes
|
||
|
||
All routes under `/discover/*` are registered in `routes/web.php` and handled by `App\Http\Controllers\Web\DiscoverController`. All use **Meilisearch sorting** — no SQL `ORDER BY` in the hot path.
|
||
|
||
| Route | Name | Sort key | Auth |
|
||
|---|---|---|---|
|
||
| `/discover/trending` | `discover.trending` | `trending_score_7d:desc` | No |
|
||
| `/discover/fresh` | `discover.fresh` | `created_at:desc` | No |
|
||
| `/discover/top-rated` | `discover.top-rated` | `likes:desc` | No |
|
||
| `/discover/most-downloaded` | `discover.most-downloaded` | `downloads:desc` | No |
|
||
| `/discover/following` | `discover.following` | `created_at:desc` (DB) | Yes |
|
||
|
||
---
|
||
|
||
## 6. Following Feed
|
||
|
||
**Route:** `GET /discover/following` (auth required)
|
||
**Controller:** `DiscoverController::following()`
|
||
|
||
### Logic
|
||
|
||
```
|
||
1. Get user's following IDs from user_followers
|
||
2. If empty → show empty state (see below)
|
||
3. If present → Artwork::whereIn('user_id', $followingIds)
|
||
->orderByDesc('published_at')
|
||
->paginate(24)
|
||
+ cached 1 min per user per page
|
||
```
|
||
|
||
### Empty state
|
||
|
||
When the user follows nobody:
|
||
|
||
- `fallback_trending` — up to 12 trending artworks (Meilisearch, with DB fallback)
|
||
- `fallback_creators` — 8 most-followed verified users (ordered by `user_statistics.followers_count`)
|
||
- `empty: true` flag passed to the view
|
||
- The `discoverTrending()` call is wrapped in `try/catch` so a Meilisearch outage never breaks the empty state page
|
||
|
||
---
|
||
|
||
## 7. Personalized Homepage
|
||
|
||
**Controller:** `HomeController::index()`
|
||
**Service:** `App\Services\HomepageService`
|
||
|
||
### Guest sections
|
||
|
||
```php
|
||
[
|
||
'hero' => first featured artwork,
|
||
'trending' => 12 artworks sorted by trending_score_7d,
|
||
'fresh' => 12 newest artworks,
|
||
'tags' => 12 most-used tags,
|
||
'creators' => creator spotlight,
|
||
'news' => latest news posts,
|
||
]
|
||
```
|
||
|
||
### Authenticated sections (personalized)
|
||
|
||
```php
|
||
[
|
||
'hero' => same as guest,
|
||
'from_following' => artworks from followed creators (up to 12, cached 1 min),
|
||
'trending' => same as guest,
|
||
'by_tags' => artworks matching user's top 5 tags,
|
||
'by_categories' => fresh uploads in user's top 3 favourite categories,
|
||
'tags' => same as guest,
|
||
'creators' => same as guest,
|
||
'news' => same as guest,
|
||
'preferences' => { top_tags, top_categories },
|
||
]
|
||
```
|
||
|
||
### UserPreferenceService
|
||
|
||
`App\Services\UserPreferenceService::build(User $user)` — cached 5 min per user.
|
||
|
||
Computes preferences from the user's **favourited artworks**:
|
||
|
||
| Output key | Source |
|
||
|---|---|
|
||
| `top_tags` (up to 5) | Tags on artworks in `artwork_favourites` |
|
||
| `top_categories` (up to 3) | Categories on artworks in `artwork_favourites` |
|
||
| `followed_creators` | IDs from `user_followers` |
|
||
|
||
### getTrending() — Meilisearch-first
|
||
|
||
```php
|
||
Artwork::search('')
|
||
->options([
|
||
'filter' => 'is_public = true AND is_approved = true',
|
||
'sort' => ['trending_score_7d:desc', 'trending_score_24h:desc', 'views:desc'],
|
||
])
|
||
->paginate($limit, 'page', 1);
|
||
```
|
||
|
||
Falls back to `getTrendingFromDb()` — `orderByDesc('trending_score_7d')` with no correlated subqueries — when Meilisearch is unavailable.
|
||
|
||
---
|
||
|
||
## 8. Similar Artworks API
|
||
|
||
**Route:** `GET /api/art/{id}/similar`
|
||
**Controller:** `App\Http\Controllers\Api\SimilarArtworksController`
|
||
**Route name:** `api.art.similar`
|
||
**Throttle:** 60/min
|
||
**Cache:** 5 min per artwork ID
|
||
**Max results:** 12
|
||
|
||
### Similarity algorithm
|
||
|
||
Meilisearch filters are built in priority order:
|
||
|
||
```
|
||
is_public = true
|
||
is_approved = true
|
||
id != {source_id}
|
||
author_id != {source_author_id} ← same creator excluded
|
||
orientation = "{landscape|portrait}" ← only for non-square (visual coherence)
|
||
(tags = "X" OR tags = "Y" OR ...) ← tag overlap (primary signal)
|
||
OR (if no tags)
|
||
(category = "X" OR ...) ← category fallback
|
||
```
|
||
|
||
Meilisearch's own ranking then sorts by relevance within those filters. Results are mapped to a slim JSON shape: `{id, title, slug, thumb, url, author_id}`.
|
||
|
||
---
|
||
|
||
## 9. Unified Activity Feed
|
||
|
||
**Route:** `GET /community/activity?type=global|following`
|
||
**Controller:** `App\Http\Controllers\Web\CommunityActivityController`
|
||
|
||
### `activity_events` schema
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | bigint PK | |
|
||
| `actor_id` | bigint FK users | Who did the action |
|
||
| `type` | varchar | `upload` `comment` `favorite` `award` `follow` |
|
||
| `target_type` | varchar | `artwork` `user` |
|
||
| `target_id` | bigint | ID of the target object |
|
||
| `meta` | json nullable | Extra data (e.g. award tier) |
|
||
| `created_at` | timestamp | No `updated_at` — immutable events |
|
||
|
||
### Where events are recorded
|
||
|
||
| Event type | Recording point |
|
||
|---|---|
|
||
| `upload` | `UploadController::finish()` on publish |
|
||
| `follow` | `FollowService::follow()` |
|
||
| `award` | `ArtworkAwardController::store()` |
|
||
| `favorite` | `ArtworkInteractionController::favorite()` |
|
||
| `comment` | `ArtworkCommentController::store()` |
|
||
|
||
All via `ActivityEvent::record($actorId, $type, $targetType, $targetId, $meta)`.
|
||
|
||
### Feed filters
|
||
|
||
- **Global** — all recent events, newest first, paginated 30/page
|
||
- **Following** — `WHERE actor_id IN (following_ids)` — only events from users you follow
|
||
|
||
The controller enriches each event batch with its target objects in a single query per target type (no N+1).
|
||
|
||
---
|
||
|
||
## 10. Meilisearch Configuration
|
||
|
||
Configured in `config/scout.php` under `meilisearch.index-settings`.
|
||
|
||
Push settings to a running instance:
|
||
```bash
|
||
php artisan scout:sync-index-settings
|
||
```
|
||
|
||
### Artworks index settings
|
||
|
||
**Searchable attributes** (ranked in order):
|
||
1. `title`
|
||
2. `tags`
|
||
3. `author_name`
|
||
4. `description`
|
||
|
||
**Filterable attributes:**
|
||
`tags`, `category`, `content_type`, `orientation`, `resolution`, `author_id`, `is_public`, `is_approved`
|
||
|
||
**Sortable attributes:**
|
||
`created_at`, `downloads`, `likes`, `views`, `trending_score_24h`, `trending_score_7d`, `favorites_count`, `awards_received_count`, `downloads_count`
|
||
|
||
### toSearchableArray() — fields indexed per artwork
|
||
|
||
```php
|
||
[
|
||
'id', 'slug', 'title', 'description',
|
||
'author_id', 'author_name',
|
||
'category', 'content_type', 'tags',
|
||
'resolution', 'orientation',
|
||
'downloads', 'likes', 'views',
|
||
'created_at', 'is_public', 'is_approved',
|
||
'trending_score_24h', 'trending_score_7d',
|
||
'favorites_count', 'awards_received_count', 'downloads_count',
|
||
'awards' => { gold, silver, bronze, score },
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Caching Strategy
|
||
|
||
| Data | Cache key | TTL | Driver |
|
||
|---|---|---|---|
|
||
| Homepage trending | `homepage.trending.{limit}` | 5 min | Redis/file |
|
||
| Homepage fresh | `homepage.fresh.{limit}` | 5 min | Redis/file |
|
||
| Homepage hero | `homepage.hero` | 5 min | Redis/file |
|
||
| Homepage tags | `homepage.tags.{limit}` | 5 min | Redis/file |
|
||
| User preferences | `user.prefs.{user_id}` | 5 min | Redis/file |
|
||
| Following feed | `discover.following.{user_id}.p{page}` | 1 min | Redis/file |
|
||
| Similar artworks | `api.similar.{artwork_id}` | 5 min | Redis/file |
|
||
|
||
**Rules:**
|
||
- Personalized data (`from_following`, `by_tags`, `by_categories`) is **not** independently cached — it falls inside `allForUser()` which is called fresh per request.
|
||
- Long-running cache busting: the trending command and reset command do not explicitly clear cache — the TTL is short enough that stale data self-expires within one trending cycle.
|
||
|
||
---
|
||
|
||
## 12. Scheduled Jobs
|
||
|
||
All registered in `routes/console.php` via `Schedule::command()`.
|
||
|
||
| Time | Command | Purpose |
|
||
|---|---|---|
|
||
| Every 30 min | `skinbase:recalculate-trending --period=24h` | Update `trending_score_24h` |
|
||
| Every 30 min | `skinbase:recalculate-trending --period=7d --skip-index` | Update `trending_score_7d` (background) |
|
||
| 03:00 daily | `uploads:cleanup` | Remove stale draft uploads |
|
||
| 03:10 daily | `analytics:aggregate-similar-artworks` | Offline similarity metrics |
|
||
| 03:20 daily | `analytics:aggregate-feed` | Feed evaluation metrics |
|
||
| 03:30 daily | `skinbase:reset-windowed-stats --period=24h` | Zero views_24h, recompute downloads_24h |
|
||
| Monday 03:30 | `skinbase:reset-windowed-stats --period=7d` | Zero views_7d, recompute downloads_7d |
|
||
|
||
**Reset runs at 03:30** so it fires after the other maintenance tasks (03:00–03:20). The next trending recalculation (every 30 min, including ~03:30 or ~04:00) picks up the freshly-zeroed windowed stats and writes accurate trending scores.
|
||
|
||
---
|
||
|
||
## 13. Testing
|
||
|
||
All tests live under `tests/Feature/Discovery/`.
|
||
|
||
| Test file | Coverage |
|
||
|---|---|
|
||
| `ActivityEventRecordingTest.php` | `ActivityEvent::record()`, all 5 types, actor relation, meta, route smoke tests for the activity feed |
|
||
| `FollowingFeedTest.php` | Auth redirect, empty state fallback, pagination, creator exclusion |
|
||
| `HomepagePersonalizationTest.php` | Guest vs auth homepage sections, preferences shape, 200 responses |
|
||
| `SimilarArtworksApiTest.php` | 404 cases, response shape, result count ≤ 12, creator exclusion |
|
||
| `SignalTrackingTest.php` | View endpoint (404s, first count, session dedup), download endpoint (404s, DB row, guest vs auth), route names |
|
||
| `TrendingServiceTest.php` | Zero artworks, skip outside window, skip private/unapproved — _recalculate() tests skipped on SQLite (MySQL-only SQL)_ |
|
||
| `WindowedStatsTest.php` | `incrementViews/Downloads` update all 3 columns, reset command zeros views, recomputes downloads from log, window boundary correctness |
|
||
|
||
Run all discovery tests:
|
||
```bash
|
||
php artisan test tests/Feature/Discovery/
|
||
```
|
||
|
||
Run specific suite:
|
||
```bash
|
||
php artisan test tests/Feature/Discovery/SignalTrackingTest.php
|
||
```
|
||
|
||
**SQLite vs MySQL note:** Four tests in `TrendingServiceTest` are marked `.skip()` with the message _"Requires MySQL: uses GREATEST() and TIMESTAMPDIFF()"_. Run them against a real MySQL instance in CI or staging to validate the bulk UPDATE formula.
|
||
|
||
---
|
||
|
||
## 14. Operational Runbook
|
||
|
||
### Trending scores are stuck / not updating
|
||
|
||
```bash
|
||
# Check last calculated timestamp
|
||
SELECT id, title, last_trending_calculated_at FROM artworks ORDER BY last_trending_calculated_at DESC LIMIT 5;
|
||
|
||
# Manually trigger recalculation
|
||
php artisan skinbase:recalculate-trending --period=all
|
||
|
||
# Re-push scores to Meilisearch
|
||
php artisan skinbase:recalculate-trending --period=7d
|
||
```
|
||
|
||
### Windowed counters look wrong after a deploy
|
||
|
||
```bash
|
||
# Force a reset and recompute
|
||
php artisan skinbase:reset-windowed-stats --period=24h
|
||
php artisan skinbase:reset-windowed-stats --period=7d
|
||
|
||
# Then recalculate trending with fresh numbers
|
||
php artisan skinbase:recalculate-trending --period=all
|
||
```
|
||
|
||
### Meilisearch out of sync with DB
|
||
|
||
```bash
|
||
# Re-push all artworks in the trending window
|
||
php artisan skinbase:recalculate-trending --period=all
|
||
|
||
# Or full re-index
|
||
php artisan scout:import "App\Models\Artwork"
|
||
```
|
||
|
||
### Push updated index settings (after changing config/scout.php)
|
||
|
||
```bash
|
||
php artisan scout:sync-index-settings
|
||
```
|
||
|
||
### Check what the trending formula is reading
|
||
|
||
```sql
|
||
SELECT
|
||
a.id,
|
||
a.title,
|
||
a.published_at,
|
||
s.views,
|
||
s.views_24h,
|
||
s.views_7d,
|
||
s.downloads,
|
||
s.downloads_24h,
|
||
s.downloads_7d,
|
||
s.favorites,
|
||
a.trending_score_24h,
|
||
a.trending_score_7d,
|
||
a.last_trending_calculated_at
|
||
FROM artworks a
|
||
LEFT JOIN artwork_stats s ON s.artwork_id = a.id
|
||
WHERE a.is_public = 1 AND a.is_approved = 1
|
||
ORDER BY a.trending_score_7d DESC
|
||
LIMIT 20;
|
||
```
|
||
|
||
### Inspect the artwork_downloads log
|
||
|
||
```sql
|
||
-- Downloads in the last 24 hours per artwork
|
||
SELECT artwork_id, COUNT(*) as dl_24h
|
||
FROM artwork_downloads
|
||
WHERE created_at >= NOW() - INTERVAL 1 DAY
|
||
GROUP BY artwork_id
|
||
ORDER BY dl_24h DESC
|
||
LIMIT 20;
|
||
```
|