Save workspace changes
This commit is contained in:
93
.deploy/artwork-evolution-release/docs/Discover/README.md
Normal file
93
.deploy/artwork-evolution-release/docs/Discover/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Discover Pages
|
||||
|
||||
This folder documents how each public discovery surface is assembled today.
|
||||
|
||||
It is intentionally page-oriented rather than architecture-oriented.
|
||||
For the broader discovery engine, signal collection, and personalization background, also see `docs/discovery-personalization-engine.md`.
|
||||
|
||||
## Pages Covered
|
||||
|
||||
- `Trending` → `GET /discover/trending`
|
||||
- `Rising` → `GET /discover/rising`
|
||||
- `Fresh` → `GET /discover/fresh`
|
||||
- `Top Rated` → `GET /discover/top-rated`
|
||||
- `Most Downloaded` → `GET /discover/most-downloaded`
|
||||
- `Today Downloads` → `GET /downloads/today`
|
||||
- `On This Day` → `GET /discover/on-this-day`
|
||||
- `For You` → `GET /discover/for-you` (auth only)
|
||||
|
||||
## Shared Request Pipeline
|
||||
|
||||
Most Discover pages follow this pattern:
|
||||
|
||||
1. Route enters `App\Http\Controllers\Web\DiscoverController`.
|
||||
2. The controller calls `App\Services\ArtworkSearchService`.
|
||||
3. `ArtworkSearchService` queries the `artworks` Meilisearch index through Laravel Scout.
|
||||
4. The search result usually contains only search/index fields.
|
||||
5. `DiscoverController::hydrateDiscoverSearchResults()` then reloads full `Artwork` rows from MySQL with relations (`user`, `profile`, `categories`) and converts them into the view model used by Blade.
|
||||
6. Some pages can still pass through additional presentation layers such as `GridFiller`, depending on the controller action.
|
||||
|
||||
## Shared Visibility Rules
|
||||
|
||||
All search-backed pages use the same base visibility filter in `ArtworkSearchService`:
|
||||
|
||||
```text
|
||||
is_public = true AND is_approved = true
|
||||
```
|
||||
|
||||
That means an artwork must be:
|
||||
|
||||
- public
|
||||
- approved
|
||||
- present in the Meilisearch index
|
||||
|
||||
If the database row is correct but search is stale, the page can still miss the artwork until indexing catches up.
|
||||
|
||||
## Shared Cache Behavior
|
||||
|
||||
`ArtworkSearchService` uses application cache in front of Meilisearch.
|
||||
|
||||
- Default TTL: 300 seconds
|
||||
- `Rising`: 120 seconds
|
||||
- Category/content-type sort pages use per-sort TTLs, but those are outside this folder's scope
|
||||
|
||||
The page can therefore lag behind a real publish or stat change even when the underlying data is already correct.
|
||||
|
||||
## Shared Supporting Jobs
|
||||
|
||||
These jobs are active in the current Laravel 11 runtime scheduler (`routes/console.php`):
|
||||
|
||||
- `skinbase:flush-redis-stats` every 5 minutes
|
||||
- `skinbase:recalculate-trending --period=24h` every 30 minutes
|
||||
- `skinbase:recalculate-trending --period=7d --skip-index` every 30 minutes
|
||||
- `skinbase:reset-windowed-stats --period=24h` daily at 03:30
|
||||
- `skinbase:reset-windowed-stats --period=7d` weekly (Monday) at 03:30
|
||||
- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes
|
||||
- `artworks:publish-scheduled` every minute
|
||||
- `analytics:aggregate-discovery-feedback` daily at 03:25
|
||||
- `RecBuildItemPairsFromFavouritesJob` every 4 hours
|
||||
- `RecComputeSimilarByTagsJob` daily at 02:00
|
||||
- `RecComputeSimilarByBehaviorJob` daily at 02:15
|
||||
- `RecComputeSimilarHybridJob` daily at 02:30
|
||||
|
||||
## Important Scheduler Caveat
|
||||
|
||||
The codebase still contains some discovery-related schedules inside `app/Console/Kernel.php`, but the active Laravel 11 runtime schedule comes from `routes/console.php`.
|
||||
|
||||
The Rising pipeline depends on these active runtime jobs:
|
||||
|
||||
- `nova:metrics-snapshot-hourly` hourly
|
||||
- `nova:recalculate-heat` every 15 minutes
|
||||
|
||||
If Rising stops moving while Trending changes, check `php artisan schedule:list` first and confirm both jobs are still active.
|
||||
|
||||
## File Map
|
||||
|
||||
- `trending.md`
|
||||
- `rising.md`
|
||||
- `fresh.md`
|
||||
- `top-rated.md`
|
||||
- `most-downloaded.md`
|
||||
- `today-downloads.md`
|
||||
- `on-this-day.md`
|
||||
- `for-you.md`
|
||||
174
.deploy/artwork-evolution-release/docs/Discover/for-you.md
Normal file
174
.deploy/artwork-evolution-release/docs/Discover/for-you.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# For You
|
||||
|
||||
## Route
|
||||
|
||||
- URL: `GET /discover/for-you`
|
||||
- Auth required: yes
|
||||
- Controller: `App\Http\Controllers\Web\DiscoverController::forYou()`
|
||||
- Entry service: `App\Services\Recommendations\RecommendationFeedResolver`
|
||||
|
||||
## High-level flow
|
||||
|
||||
`For You` is not a simple search sort.
|
||||
It is a recommendation pipeline with engine selection, caching, layered candidate generation, reranking, and cursor pagination.
|
||||
|
||||
The controller:
|
||||
|
||||
1. reads `limit` and `cursor`
|
||||
2. calls `RecommendationFeedResolver::getFeed()`
|
||||
3. converts feed items into the artwork card view model
|
||||
4. returns HTML or JSON depending on request type
|
||||
|
||||
## Engine selection
|
||||
|
||||
`RecommendationFeedResolver` chooses between two implementations:
|
||||
|
||||
- V2: `App\Services\Recommendations\RecommendationServiceV2`
|
||||
- V1: `App\Services\Recommendations\PersonalizedFeedService`
|
||||
|
||||
Selection is based on:
|
||||
|
||||
- `config('discovery.v2.enabled')`
|
||||
- rollout percentage bucket for the current user
|
||||
- explicit `algo_version` override
|
||||
|
||||
## Cache model
|
||||
|
||||
Both engines use `user_recommendation_cache`.
|
||||
|
||||
At request time:
|
||||
|
||||
1. Load cache row for `(user_id, algo_version)`
|
||||
2. Check cache version and `expires_at`
|
||||
3. If missing or stale, dispatch `RegenerateUserRecommendationCacheJob`
|
||||
4. If the row is empty, build fallback recommendations inline for the current request
|
||||
|
||||
This means the page is usually cache-backed, but it does not hard-fail if the cache is cold.
|
||||
|
||||
## V2 pipeline
|
||||
|
||||
V2 is the richer layered engine.
|
||||
|
||||
### Candidate layers
|
||||
|
||||
The candidate pool is blended from:
|
||||
|
||||
- personalized layer
|
||||
- social layer
|
||||
- trending layer
|
||||
- exploration layer
|
||||
- vector layer (only when V3/vector support is enabled and configured)
|
||||
|
||||
Default target ratios from `config/discovery.php`:
|
||||
|
||||
- personalized: 50%
|
||||
- social: 20%
|
||||
- trending: 20%
|
||||
- exploration: 10%
|
||||
|
||||
### Main V2 score
|
||||
|
||||
For each candidate row:
|
||||
|
||||
```text
|
||||
score
|
||||
= (base_score * weight_base)
|
||||
+ session_boost
|
||||
+ social_boost
|
||||
+ trending_boost
|
||||
+ exploration_boost
|
||||
+ creator_boost
|
||||
+ vector_boost
|
||||
- negative_penalty
|
||||
- repetition_penalty
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `session_boost` comes from merged session/profile signals
|
||||
- `social_boost` comes from followed creators and artworks liked by followed creators
|
||||
- `trending_boost` is built from `trending_score_1h`, `trending_score_24h`, `trending_score_7d`
|
||||
- `exploration_boost` rewards fresh uploads, new creators, and unseen tags
|
||||
- `creator_boost` uses creator follower count plus artwork momentum metrics
|
||||
- `vector_boost` comes from visual similarity when vector mode is enabled
|
||||
- `negative_penalty` reflects hidden artworks and disliked tags
|
||||
- `repetition_penalty` suppresses creator and tag repetition inside one result page
|
||||
|
||||
### Trending contribution inside V2
|
||||
|
||||
V2 combines the artwork's stored trending columns like this:
|
||||
|
||||
```text
|
||||
trendingBoost
|
||||
= (trending_score_1h * weight_1h)
|
||||
+ (trending_score_24h * weight_24h)
|
||||
+ (trending_score_7d * weight_7d)
|
||||
```
|
||||
|
||||
Then divides by 100 before merging into the final score.
|
||||
|
||||
### Negative signals
|
||||
|
||||
V2 reads `user_negative_signals` and applies:
|
||||
|
||||
- hidden artwork exclusion
|
||||
- disliked tag penalty
|
||||
|
||||
### Supporting data sources
|
||||
|
||||
V2 reads from:
|
||||
|
||||
- `user_recommendation_cache`
|
||||
- session/profile signal builders
|
||||
- `artworks`
|
||||
- `artwork_stats`
|
||||
- `artwork_similarities`
|
||||
- `artwork_embeddings` and vector service, when enabled
|
||||
- `user_followers`
|
||||
- `artwork_favourites`
|
||||
- `user_negative_signals`
|
||||
|
||||
## V1 pipeline
|
||||
|
||||
V1 is simpler and category-affinity driven.
|
||||
|
||||
It reads `user_interest_profiles`, then scores candidates with a weighted blend:
|
||||
|
||||
```text
|
||||
score = (w1 * affinity)
|
||||
+ (w2 * recency)
|
||||
+ (w3 * popularity)
|
||||
+ (w4 * novelty)
|
||||
```
|
||||
|
||||
Cold start falls back to a blend of popular artworks and `artwork_similarities` seeds.
|
||||
|
||||
## Background jobs and schedules
|
||||
|
||||
### Directly relevant
|
||||
|
||||
- `RegenerateUserRecommendationCacheJob`
|
||||
- dispatched on demand when cache is missing or stale
|
||||
|
||||
### Support jobs for candidate quality
|
||||
|
||||
- `RecBuildItemPairsFromFavouritesJob` every 4 hours
|
||||
- `RecComputeSimilarByTagsJob` daily at 02:00
|
||||
- `RecComputeSimilarByBehaviorJob` daily at 02:15
|
||||
- `RecComputeSimilarHybridJob` daily at 02:30
|
||||
- `analytics:aggregate-discovery-feedback` daily at 03:25
|
||||
|
||||
These jobs do not directly render the page, but they improve the offline inputs and behavioral data used by the recommender.
|
||||
|
||||
## Cache behavior
|
||||
|
||||
- V1 cache TTL default: 60 minutes
|
||||
- V2 cache TTL default: 15 minutes
|
||||
|
||||
Cursor pagination is offset-based under the hood.
|
||||
|
||||
## Notes
|
||||
|
||||
- `For You` is the most configuration-sensitive page in this set.
|
||||
- What a given user sees can differ by rollout bucket, `algo_version`, cache state, and whether V2/V3 features are enabled.
|
||||
- If you are debugging a single user's page, inspect `RecommendationFeedResolver::inspectDecision()` first.
|
||||
88
.deploy/artwork-evolution-release/docs/Discover/fresh.md
Normal file
88
.deploy/artwork-evolution-release/docs/Discover/fresh.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Fresh
|
||||
|
||||
## Route
|
||||
|
||||
- URL: `GET /discover/fresh`
|
||||
- Controller: `App\Http\Controllers\Web\DiscoverController::fresh()`
|
||||
- Service: `App\Services\ArtworkSearchService::discoverFresh()`
|
||||
|
||||
## What the page reads
|
||||
|
||||
Fresh is Meilisearch-backed, then hydrated from MySQL.
|
||||
|
||||
The page does not directly query `artworks` for ranking order.
|
||||
It relies on the search index being up to date.
|
||||
|
||||
If the search-backed result comes back empty, the controller falls back to a direct MySQL query ordered by `published_at DESC, id DESC` so the page does not render blank while search is catching up.
|
||||
|
||||
## Search query
|
||||
|
||||
`discoverFresh()` uses:
|
||||
|
||||
- filter: `is_public = true AND is_approved = true`
|
||||
- sort:
|
||||
- `published_at_ts:desc`
|
||||
|
||||
`published_at_ts` is a numeric timestamp field stored in the search document specifically so same-day uploads can be ordered correctly by hour and minute.
|
||||
|
||||
## Why the dedicated timestamp field exists
|
||||
|
||||
Historically, the index sorted by a date-only `created_at` string, which meant all uploads on the same calendar day could collapse to the same sort value.
|
||||
|
||||
The current implementation uses `published_at_ts` to preserve intra-day ordering and avoid newer uploads being buried behind older uploads from the same date.
|
||||
|
||||
## Page behavior
|
||||
|
||||
Fresh now uses the raw newest-first search result without any curated blending or grid filler injection.
|
||||
|
||||
That means:
|
||||
|
||||
- page 1 is not mixed with spotlight content
|
||||
- page 1 is not padded with older trending artworks
|
||||
- deeper pages follow the same ordering model as page 1
|
||||
|
||||
If older artworks appear near the top, the likely causes are stale search documents or stale cache, not intentional feed mixing.
|
||||
|
||||
If the page renders completely empty even though recent public artworks exist, the DB fallback should populate it. A blank page after that points to a real data visibility problem, not just search freshness.
|
||||
|
||||
## Data sources
|
||||
|
||||
Ranking eligibility depends on:
|
||||
|
||||
- `is_public`
|
||||
- `is_approved`
|
||||
- presence in Meilisearch
|
||||
- `published_at_ts` in the indexed document
|
||||
|
||||
Hydration reads full rows from MySQL after the search query returns IDs.
|
||||
|
||||
When the fallback path is used, the page is served directly from MySQL and does not require Meilisearch for that request.
|
||||
|
||||
## Relevant jobs and schedules
|
||||
|
||||
Fresh does not have a dedicated score calculation job.
|
||||
It depends on publication and indexing freshness.
|
||||
|
||||
Relevant active schedules:
|
||||
|
||||
- `artworks:publish-scheduled` every minute
|
||||
- `skinbase:flush-redis-stats` every 5 minutes (not for ordering, but for displayed stats freshness)
|
||||
|
||||
Index freshness depends on:
|
||||
|
||||
- normal Scout indexing from artwork updates
|
||||
- scheduled-publication indexing after an artwork transitions from scheduled to published
|
||||
- manual/full imports after search-document schema changes
|
||||
|
||||
## Cache behavior
|
||||
|
||||
- Cache key: `discover.fresh.{page}`
|
||||
- TTL: 300 seconds
|
||||
|
||||
## Notes
|
||||
|
||||
- Fresh is the page most sensitive to stale indexing because it is supposed to surface the latest publish action immediately.
|
||||
- If an artwork is public in MySQL but absent from Fresh, the usual causes are:
|
||||
- it has not been indexed yet
|
||||
- app cache has not expired yet
|
||||
- the artwork is not actually public and approved at the same time
|
||||
@@ -0,0 +1,59 @@
|
||||
# Most Downloaded
|
||||
|
||||
## Route
|
||||
|
||||
- URL: `GET /discover/most-downloaded`
|
||||
- Controller: `App\Http\Controllers\Web\DiscoverController::mostDownloaded()`
|
||||
- Service: `App\Services\ArtworkSearchService::discoverMostDownloaded()`
|
||||
|
||||
## What the page reads
|
||||
|
||||
This page is Meilisearch-ranked and then hydrated from MySQL.
|
||||
|
||||
## Search query
|
||||
|
||||
`discoverMostDownloaded()` uses:
|
||||
|
||||
- filter: `is_public = true AND is_approved = true`
|
||||
- sort:
|
||||
- `downloads:desc`
|
||||
- `views:desc`
|
||||
|
||||
In the indexed document:
|
||||
|
||||
- `downloads` is sourced from `artwork_stats.downloads`
|
||||
- `views` is sourced from `artwork_stats.views`
|
||||
|
||||
This is therefore an all-time leaderboard, not a rolling-window leaderboard.
|
||||
|
||||
## Where the download counts come from
|
||||
|
||||
Downloads are recorded in two places:
|
||||
|
||||
1. `artwork_downloads`
|
||||
- full event log of each tracked download
|
||||
2. `artwork_stats.downloads`
|
||||
- all-time aggregate counter used by this page
|
||||
|
||||
The page sorts on the aggregate counter, not by counting the event log live.
|
||||
|
||||
## Background jobs and schedules
|
||||
|
||||
No dedicated page-specific cron exists.
|
||||
|
||||
Relevant active maintenance:
|
||||
|
||||
- `skinbase:flush-redis-stats` every 5 minutes
|
||||
- pushes deferred Redis counters into MySQL
|
||||
|
||||
Window reset commands exist too, but they maintain `downloads_24h` and `downloads_7d` rather than the all-time `downloads` column used by this page.
|
||||
|
||||
## Cache behavior
|
||||
|
||||
- Cache key: `discover.most-downloaded.{page}`
|
||||
- TTL: 300 seconds
|
||||
|
||||
## Notes
|
||||
|
||||
- This page is separate from `Today Downloads`.
|
||||
- If you need "what is hot today by download activity," use the dedicated `/downloads/today` page instead.
|
||||
@@ -0,0 +1,52 @@
|
||||
# On This Day
|
||||
|
||||
## Route
|
||||
|
||||
- URL: `GET /discover/on-this-day`
|
||||
- Controller: `App\Http\Controllers\Web\DiscoverController::onThisDay()`
|
||||
|
||||
## What the page reads
|
||||
|
||||
This page is a direct MySQL query over `artworks`.
|
||||
It does not use Meilisearch.
|
||||
|
||||
## Query logic
|
||||
|
||||
The controller selects artworks that are:
|
||||
|
||||
- public
|
||||
- published
|
||||
- approved
|
||||
- published on the same month/day as today
|
||||
- from a previous year only
|
||||
|
||||
It then sorts them by `published_at DESC` and paginates 24 per page.
|
||||
|
||||
Equivalent logic:
|
||||
|
||||
```text
|
||||
MONTH(published_at) = today.month
|
||||
AND DAY(published_at) = today.day
|
||||
AND YEAR(published_at) < today.year
|
||||
```
|
||||
|
||||
## Data sources
|
||||
|
||||
- primary source: `artworks`
|
||||
- supporting eager loads: `user`, `user.profile`, `categories`
|
||||
|
||||
## Cache behavior
|
||||
|
||||
- no explicit controller cache
|
||||
|
||||
## Background jobs and schedules
|
||||
|
||||
No dedicated cron drives this page.
|
||||
|
||||
It only depends on correct `published_at` values and the usual public/published scopes.
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a calendar-history page, not a popularity or momentum page.
|
||||
- The current implementation simply orders by newest qualifying `published_at`, not by views, downloads, or favourites.
|
||||
- There are also legacy `TodayInHistoryController` variants elsewhere in the codebase, but `/discover/on-this-day` currently uses `DiscoverController::onThisDay()`.
|
||||
115
.deploy/artwork-evolution-release/docs/Discover/rising.md
Normal file
115
.deploy/artwork-evolution-release/docs/Discover/rising.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Rising
|
||||
|
||||
## Route
|
||||
|
||||
- URL: `GET /discover/rising`
|
||||
- Controller: `App\Http\Controllers\Web\DiscoverController::rising()`
|
||||
- Service: `App\Services\ArtworkSearchService::discoverRising()`
|
||||
- RSS feed: `GET /rss/discover/rising` via `App\Http\Controllers\RSS\DiscoverFeedController::rising()`
|
||||
|
||||
## What the page reads
|
||||
|
||||
Like most Discover surfaces, this page ranks via Meilisearch and then hydrates the result IDs from MySQL for presentation.
|
||||
|
||||
If the search-backed query throws or returns no items, the controller falls back to a direct MySQL query against `artworks` + `artwork_stats`.
|
||||
|
||||
If the page receives a non-empty result set but every item has zero `heat_score` and zero `engagement_velocity`, it switches to a low-signal fallback policy instead of pretending that the zero-heat order is meaningful.
|
||||
|
||||
The RSS Rising feed now follows the same low-signal policy and the same adaptive lookback window, so it does not drift to a stale zero-heat ordering when recent engagement is sparse.
|
||||
|
||||
Primary ranking fields:
|
||||
|
||||
- `heat_score`
|
||||
- `engagement_velocity`
|
||||
- `published_at_ts` as the final recency tie-breaker
|
||||
|
||||
## Search query
|
||||
|
||||
`discoverRising()` uses:
|
||||
|
||||
- filter: `is_public = true AND is_approved = true AND created_at >= cutoff`
|
||||
- sort:
|
||||
- `heat_score:desc`
|
||||
- `engagement_velocity:desc`
|
||||
- `published_at_ts:desc`
|
||||
|
||||
The cutoff comes from the same adaptive time-window service used by Trending.
|
||||
|
||||
## Rising formula
|
||||
|
||||
`heat_score` is produced by `App\Console\Commands\RecalculateHeatCommand`.
|
||||
|
||||
Current formula:
|
||||
|
||||
```text
|
||||
raw_heat
|
||||
= ((views_delta * 1)
|
||||
+ (downloads_delta * 3)
|
||||
+ (favourites_delta * 6)
|
||||
+ (comments_delta * 8)
|
||||
+ (shares_delta * 12)) / window_hours
|
||||
|
||||
age_factor
|
||||
= 1 / (1 + hours_since_upload / 24)
|
||||
|
||||
heat_score
|
||||
= raw_heat * age_factor
|
||||
```
|
||||
|
||||
The heat command smooths deltas over a trailing lookback window, rather than relying only on the last single hour.
|
||||
|
||||
That matters on low-traffic periods, because a pure 1-hour delta often collapses to zero for almost every artwork.
|
||||
|
||||
An artwork still needs at least two snapshots inside that window for the smoothed heat delta to count. A single snapshot without an earlier baseline does not count as momentum.
|
||||
|
||||
The `views_1h`, `downloads_1h`, `favourites_1h`, `comments_1h`, and `shares_1h` columns are still stored from the previous-hour comparison for diagnostics and dashboards.
|
||||
|
||||
## Data sources
|
||||
|
||||
The page depends on:
|
||||
|
||||
- `artwork_metric_snapshots_hourly`
|
||||
- `artwork_stats.heat_score`
|
||||
- `artwork_stats.engagement_velocity`
|
||||
- artwork publish timestamps
|
||||
|
||||
In zero-signal periods, the fallback policy also uses a 24-hour snapshot delta rollup from `artwork_metric_snapshots_hourly` and then falls back to `published_at DESC`.
|
||||
|
||||
`engagement_velocity` is not part of the heat command. It comes from the ranking engine and acts as a secondary momentum signal.
|
||||
|
||||
## Intended background jobs
|
||||
|
||||
The intended pipeline is:
|
||||
|
||||
1. `nova:metrics-snapshot-hourly`
|
||||
- captures hourly totals into `artwork_metric_snapshots_hourly`
|
||||
2. `nova:recalculate-heat`
|
||||
- computes `heat_score` from snapshot deltas
|
||||
3. Meilisearch picks up the updated score after indexing
|
||||
|
||||
## Runtime schedule
|
||||
|
||||
Rising depends on two active Laravel 11 runtime jobs in `routes/console.php`:
|
||||
|
||||
- `nova:metrics-snapshot-hourly`
|
||||
- `nova:recalculate-heat`
|
||||
|
||||
If either one disappears from `php artisan schedule:list`, Rising will quickly drift toward stale or low-signal ordering.
|
||||
|
||||
## Active jobs that still affect Rising
|
||||
|
||||
- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes updates `engagement_velocity`
|
||||
- `skinbase:flush-redis-stats` every 5 minutes keeps all-time stats fresher
|
||||
|
||||
## Cache behavior
|
||||
|
||||
- Cache key: `discover.rising.{windowDays}d.{page}`
|
||||
- TTL: 120 seconds
|
||||
|
||||
If Meilisearch sort settings are missing or the search result is empty, the controller falls back to the DB query instead of returning an empty page or a 500.
|
||||
|
||||
## Notes
|
||||
|
||||
- If Rising looks frozen while Trending moves, the first place to check is whether `nova:metrics-snapshot-hourly` and `nova:recalculate-heat` are actually being executed in production.
|
||||
- The page no longer uses `GridFiller`, so it should not pull in unrelated older artworks when the real result set is thin.
|
||||
- If all heat and velocity values are zero, Rising intentionally behaves like a low-signal discovery feed: recent activity in the last 24 hours first, then newest published artworks.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Today Downloads
|
||||
|
||||
## Route
|
||||
|
||||
- URL: `GET /downloads/today`
|
||||
- Controller: `App\Http\Controllers\User\TodayDownloadsController::index()`
|
||||
|
||||
## Important scope note
|
||||
|
||||
This page is not inside `/discover/*`, but it is included here because it is discovery-adjacent and was requested together with the Discover surfaces.
|
||||
|
||||
## What the page reads
|
||||
|
||||
This page does not use Meilisearch.
|
||||
It is a direct MySQL query over the download event log.
|
||||
|
||||
## Query logic
|
||||
|
||||
The controller:
|
||||
|
||||
1. Takes today's date
|
||||
2. Reads `artwork_downloads`
|
||||
3. Filters rows to `whereDate(created_at, today)`
|
||||
4. Groups by `artwork_id`
|
||||
5. Orders by `COUNT(*) DESC`
|
||||
6. Eager-loads each artwork and related user/category data
|
||||
|
||||
Effectively:
|
||||
|
||||
```text
|
||||
SELECT artwork_id, COUNT(*) AS num_downloads
|
||||
FROM artwork_downloads
|
||||
WHERE DATE(created_at) = today
|
||||
GROUP BY artwork_id
|
||||
ORDER BY num_downloads DESC
|
||||
```
|
||||
|
||||
Only artworks that are currently public and published are allowed through `whereHas('artwork', ...)`.
|
||||
|
||||
## Data sources
|
||||
|
||||
- primary source: `artwork_downloads`
|
||||
- supporting source: `artworks`, `users`, `user_profiles`, `categories`
|
||||
|
||||
Unlike `Most Downloaded`, this page does not trust the aggregate `artwork_stats.downloads` counter for ranking.
|
||||
It re-counts today's actual events.
|
||||
|
||||
## Cache behavior
|
||||
|
||||
- no dedicated application cache in the controller
|
||||
|
||||
The page is as fresh as the underlying event log.
|
||||
|
||||
## Background jobs and schedules
|
||||
|
||||
No page-specific cron is needed because it reads the raw event log directly.
|
||||
|
||||
The only prerequisite is that downloads are being recorded correctly by the download endpoint.
|
||||
|
||||
## Notes
|
||||
|
||||
- This page is often the more operationally trustworthy answer to "what is being downloaded right now?"
|
||||
- Because it counts raw rows, it is less sensitive to delayed aggregate-counter flushes than `Most Downloaded`.
|
||||
58
.deploy/artwork-evolution-release/docs/Discover/top-rated.md
Normal file
58
.deploy/artwork-evolution-release/docs/Discover/top-rated.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Top Rated
|
||||
|
||||
## Route
|
||||
|
||||
- URL: `GET /discover/top-rated`
|
||||
- Controller: `App\Http\Controllers\Web\DiscoverController::topRated()`
|
||||
- Service: `App\Services\ArtworkSearchService::discoverTopRated()`
|
||||
|
||||
## What the page reads
|
||||
|
||||
This is a Meilisearch-ranked page with MySQL hydration after the fact.
|
||||
|
||||
## Search query
|
||||
|
||||
`discoverTopRated()` uses:
|
||||
|
||||
- filter: `is_public = true AND is_approved = true`
|
||||
- sort:
|
||||
- `likes:desc`
|
||||
- `views:desc`
|
||||
|
||||
In the indexed document:
|
||||
|
||||
- `likes` is sourced from `artwork_stats.favorites`
|
||||
- `views` is sourced from `artwork_stats.views`
|
||||
|
||||
So "Top Rated" really means highest favourite count, with views as a tie-breaker.
|
||||
|
||||
## Data sources
|
||||
|
||||
The page depends on:
|
||||
|
||||
- `artwork_stats.favorites`
|
||||
- `artwork_stats.views`
|
||||
- Scout/Meilisearch document freshness
|
||||
|
||||
It does not run a custom score formula beyond the sort order above.
|
||||
|
||||
## Background jobs and schedules
|
||||
|
||||
There is no dedicated top-rated cron.
|
||||
The page depends on the freshness of the underlying stats.
|
||||
|
||||
Relevant active maintenance:
|
||||
|
||||
- `skinbase:flush-redis-stats` every 5 minutes for deferred stat deltas
|
||||
|
||||
Favorites themselves are typically updated in the normal request path rather than by a dedicated scheduled command.
|
||||
|
||||
## Cache behavior
|
||||
|
||||
- Cache key: `discover.top-rated.{page}`
|
||||
- TTL: 300 seconds
|
||||
|
||||
## Notes
|
||||
|
||||
- Awards are not part of this page's ranking.
|
||||
- If the business meaning should be "best overall" rather than "most favourited," this page would need a different sort field.
|
||||
125
.deploy/artwork-evolution-release/docs/Discover/trending.md
Normal file
125
.deploy/artwork-evolution-release/docs/Discover/trending.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Trending
|
||||
|
||||
## Route
|
||||
|
||||
- URL: `GET /discover/trending`
|
||||
- Controller: `App\Http\Controllers\Web\DiscoverController::trending()`
|
||||
- Service: `App\Services\ArtworkSearchService::discoverTrending()`
|
||||
|
||||
## What the page actually reads
|
||||
|
||||
Primary ranking comes from Meilisearch, not directly from MySQL.
|
||||
|
||||
The controller flow is:
|
||||
|
||||
1. Query Meilisearch through `ArtworkSearchService`
|
||||
2. If the search-backed query throws or returns no items, fall back to a direct MySQL query against `artworks` + `artwork_stats`
|
||||
3. Hydrate the returned IDs back into full `Artwork` Eloquent models when needed
|
||||
4. Render `resources/views/web/discover/index.blade.php`
|
||||
|
||||
So the page is effectively:
|
||||
|
||||
- ranking source: Meilisearch
|
||||
- fallback ranking source: MySQL + `artwork_stats`
|
||||
- display hydration source: MySQL
|
||||
|
||||
## Search query
|
||||
|
||||
`discoverTrending()` uses:
|
||||
|
||||
- filter: `is_public = true AND is_approved = true AND created_at >= cutoff`
|
||||
- sort:
|
||||
- `ranking_score:desc`
|
||||
- `engagement_velocity:desc`
|
||||
- `views:desc`
|
||||
|
||||
`created_at` here is the search index field, not necessarily the raw DB column semantics.
|
||||
|
||||
## Time window
|
||||
|
||||
The cutoff is not hardcoded to 30 days in all cases.
|
||||
|
||||
`App\Services\EarlyGrowth\AdaptiveTimeWindow` chooses the effective look-back window:
|
||||
|
||||
- 7 days when uploads/day is healthy
|
||||
- 30 days in moderate activity
|
||||
- 90 days in low activity
|
||||
|
||||
That widening only changes which artworks are eligible for the query.
|
||||
It does not rewrite timestamps.
|
||||
|
||||
## Ranking formula
|
||||
|
||||
The page does not sort by `trending_score_7d` anymore.
|
||||
It sorts by `ranking_score`, which is produced by `App\Services\Ranking\ArtworkRankingService`.
|
||||
|
||||
Current V2 ranking formula:
|
||||
|
||||
```text
|
||||
base_score
|
||||
= (views_all * 0.2)
|
||||
+ (downloads_all * 1.5)
|
||||
+ (favourites_all * 2.5)
|
||||
+ (comments_count * 3.0)
|
||||
+ (shares_count * 4.0)
|
||||
|
||||
authority_multiplier
|
||||
= 1 + ((log10(1 + author_followers_count)
|
||||
+ (author_favourites_received / 1000)) * 0.05)
|
||||
|
||||
decay_factor
|
||||
= 1 / (1 + age_hours / 48)
|
||||
|
||||
velocity_boost
|
||||
= ((views_24h * 1.0)
|
||||
+ (favourites_24h * 3.0)
|
||||
+ (comments_24h * 4.0)
|
||||
+ (shares_24h * 5.0)) * 0.5
|
||||
|
||||
ranking_score
|
||||
= (base_score * authority_multiplier * decay_factor) + velocity_boost
|
||||
```
|
||||
|
||||
`engagement_velocity` is the raw `velocity_boost` term stored separately in `artwork_stats`.
|
||||
|
||||
## Where the numbers come from
|
||||
|
||||
The page depends mainly on:
|
||||
|
||||
- `artwork_stats.ranking_score`
|
||||
- `artwork_stats.engagement_velocity`
|
||||
- `artwork_stats.views`
|
||||
- `artwork_stats.downloads`
|
||||
- `artwork_stats.favorites`
|
||||
- `artwork_stats.comments_count`
|
||||
- `artwork_stats.shares_count`
|
||||
- author-level follower and favourites-received signals
|
||||
|
||||
Those values are copied into the search document by `Artwork::toSearchableArray()`.
|
||||
|
||||
## Active jobs and schedules
|
||||
|
||||
Relevant active schedules:
|
||||
|
||||
- `nova:recalculate-rankings --sync-rank-scores` every 30 minutes
|
||||
- `skinbase:flush-redis-stats` every 5 minutes
|
||||
|
||||
Indirectly relevant:
|
||||
|
||||
- `artworks:publish-scheduled` every minute
|
||||
- Meilisearch indexing jobs dispatched after score recalculation
|
||||
|
||||
## Cache behavior
|
||||
|
||||
- Cache key: `discover.trending.{windowDays}d.{page}`
|
||||
- TTL: 300 seconds
|
||||
|
||||
That means a ranking update can be correct in Meilisearch but still hidden by app cache for up to 5 minutes.
|
||||
|
||||
If Meilisearch sort settings are missing or the index returns no results, the controller falls back to a DB query instead of rendering a 500 or an empty page.
|
||||
|
||||
## Notes
|
||||
|
||||
- The view text still says "most-viewed artworks on Skinbase over the past 7 days," but the current implementation is really a `ranking_score` page with a dynamic eligibility window.
|
||||
- The page is only as fresh as both the index and the app cache.
|
||||
- The page no longer uses `GridFiller`, so it should not inject older out-of-window artworks just to pad page 1.
|
||||
125
.deploy/artwork-evolution-release/docs/QUEUE.md
Normal file
125
.deploy/artwork-evolution-release/docs/QUEUE.md
Normal file
@@ -0,0 +1,125 @@
|
||||
Queue worker setup
|
||||
==================
|
||||
|
||||
This document explains how to run Laravel queue workers for Skinbase and suggested Supervisor / systemd configs included in `deploy/`.
|
||||
|
||||
1) Choose a queue driver
|
||||
------------------------
|
||||
|
||||
Pick a driver in your `.env`, for example using the database driver (simple to run locally):
|
||||
|
||||
```
|
||||
QUEUE_CONNECTION=database
|
||||
```
|
||||
|
||||
Or use Redis for production:
|
||||
|
||||
```
|
||||
QUEUE_CONNECTION=redis
|
||||
```
|
||||
|
||||
2) Database queue (if using database driver)
|
||||
-------------------------------------------
|
||||
|
||||
Create the jobs table and run migrations:
|
||||
|
||||
```bash
|
||||
php artisan queue:table
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
3) Supervisor (recommended for many setups)
|
||||
-------------------------------------------
|
||||
|
||||
We provide an example Supervisor config at `deploy/supervisor/skinbase-queue.conf`.
|
||||
|
||||
The example worker listens on the application queues currently used by Skinbase features, including AI and discovery jobs:
|
||||
|
||||
```text
|
||||
forum-security,forum-moderation,vision,recommendations,discovery,mail,default
|
||||
```
|
||||
|
||||
If you split workloads across dedicated workers, make sure any queues configured through `VISION_QUEUE`, `RECOMMENDATIONS_QUEUE`, or `DISCOVERY_QUEUE` are explicitly covered by at least one worker process.
|
||||
|
||||
To use it on a Debian/Ubuntu server:
|
||||
|
||||
```bash
|
||||
# copy the file to supervisor's config directory
|
||||
sudo cp deploy/supervisor/skinbase-queue.conf /etc/supervisor/conf.d/skinbase-queue.conf
|
||||
# make sure the logs dir exists
|
||||
sudo mkdir -p /var/log/skinbase
|
||||
sudo chown www-data:www-data /var/log/skinbase
|
||||
# tell supervisor to reload configs and start
|
||||
sudo supervisorctl reread
|
||||
sudo supervisorctl update
|
||||
sudo supervisorctl start skinbase-queue
|
||||
# check status
|
||||
sudo supervisorctl status skinbase-queue
|
||||
```
|
||||
|
||||
Adjust the `command` and `user` in the conf to match your deployment (path to PHP, project root and user).
|
||||
|
||||
4) systemd alternative
|
||||
----------------------
|
||||
|
||||
If you prefer systemd, an example unit is at `deploy/systemd/skinbase-queue.service`.
|
||||
|
||||
```bash
|
||||
sudo cp deploy/systemd/skinbase-queue.service /etc/systemd/system/skinbase-queue.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now skinbase-queue.service
|
||||
sudo systemctl status skinbase-queue.service
|
||||
```
|
||||
|
||||
Adjust `WorkingDirectory` and `User` in the unit to match your deployment.
|
||||
|
||||
5) Helpful artisan commands
|
||||
---------------------------
|
||||
|
||||
- Start a one-off worker (foreground):
|
||||
|
||||
```bash
|
||||
php artisan queue:work --sleep=3 --tries=3
|
||||
```
|
||||
|
||||
- Restart all workers gracefully (useful after deployments):
|
||||
|
||||
```bash
|
||||
php artisan queue:restart
|
||||
```
|
||||
|
||||
- Inspect failed jobs:
|
||||
|
||||
```bash
|
||||
php artisan queue:failed
|
||||
php artisan queue:retry {id}
|
||||
php artisan queue:flush
|
||||
```
|
||||
|
||||
6) Logging & monitoring
|
||||
-----------------------
|
||||
|
||||
- Supervisor example logs to `/var/log/skinbase/queue.log` (see `deploy/supervisor/skinbase-queue.conf`).
|
||||
- Use `journalctl -u skinbase-queue` for systemd logs.
|
||||
|
||||
7) Notes and troubleshooting
|
||||
---------------------------
|
||||
|
||||
- Ensure `QUEUE_CONNECTION` in `.env` matches the driver you've configured.
|
||||
- If using `database` driver, the `jobs` and `failed_jobs` tables must exist.
|
||||
- The mailable used for contact submissions is queued; if the queue worker is not running mails will accumulate in the queue table (or Redis).
|
||||
- AI auto-tagging, embedding generation, vector index repair, and recommendation cache regeneration are all queued. If you move them onto dedicated queues, the shipped example worker commands must be updated to consume those queue names.
|
||||
|
||||
8) AI and discovery queue checklist
|
||||
-----------------------------------
|
||||
|
||||
If you are enabling the recommendation AI stack in production, verify all of the following:
|
||||
|
||||
- `QUEUE_CONNECTION` is backed by a real worker-capable driver such as `redis` or `database`
|
||||
- `VISION_QUEUE`, `RECOMMENDATIONS_QUEUE`, and `DISCOVERY_QUEUE` either stay on `default` or are present in a worker `--queue=` list
|
||||
- `UPLOAD_QUEUE_DERIVATIVES=true` has workers online before you enable it, otherwise upload post-processing will stall
|
||||
- `php artisan queue:restart` is run after deploys so workers pick up new code and config
|
||||
|
||||
See `docs/recommendation-ai-production.md` for a stack-specific runbook.
|
||||
|
||||
Questions or prefer a different process manager? Tell me your target host and I can produce exact commands tailored to it.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Avatar CDN Config Notes
|
||||
|
||||
This project serves avatars from the avatar CDN domain.
|
||||
|
||||
## Required env variables
|
||||
|
||||
- `AVATAR_CDN_URL=https://files.skinbase.org`
|
||||
- `AVATAR_DISK=s3` (production)
|
||||
- `AVATAR_WEBP_QUALITY=85`
|
||||
|
||||
## Delivery format
|
||||
|
||||
Avatars are rendered via:
|
||||
|
||||
- `https://files.skinbase.org/avatars/{user_id}/{size}.webp?v={avatar_hash}`
|
||||
|
||||
Sizes generated server-side:
|
||||
|
||||
- `32`, `64`, `128`, `256`, `512`
|
||||
|
||||
## Cache policy
|
||||
|
||||
Storage writes must set:
|
||||
|
||||
- `Cache-Control: public, max-age=31536000, immutable`
|
||||
|
||||
Hash-based query versioning (`?v={avatar_hash}`) handles cache busting.
|
||||
|
||||
## Production rule
|
||||
|
||||
- Production avatar storage must use object storage (`s3` / R2-compatible disk).
|
||||
- Local/public disks are for development only.
|
||||
45
.deploy/artwork-evolution-release/docs/categories.md
Normal file
45
.deploy/artwork-evolution-release/docs/categories.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# SkinBase – Category System (NEW SQL Structure)
|
||||
|
||||
This document defines the **new category & taxonomy system** for SkinBase.
|
||||
Copilot AI Agent must follow this structure strictly and MUST NOT reuse legacy logic.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goals
|
||||
|
||||
- SEO-friendly URLs (no IDs in public routes)
|
||||
- Clear separation of content types (Photography, Skins, Wallpapers, etc.)
|
||||
- Unlimited category nesting
|
||||
- Laravel-friendly (Eloquent, migrations, relations)
|
||||
- Ready for sitemap, breadcrumbs, translations
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Legacy System (DO NOT USE)
|
||||
|
||||
The old table `artworks_categories` is deprecated.
|
||||
|
||||
DO NOT:
|
||||
- use `section_id`
|
||||
- use `rootid`
|
||||
- use `num_artworks`
|
||||
- expose IDs in URLs
|
||||
- infer hierarchy from numeric hacks
|
||||
|
||||
---
|
||||
|
||||
## ✅ New Database Structure
|
||||
|
||||
### 1️⃣ content_types
|
||||
|
||||
Top-level sections (URL level 1)
|
||||
|
||||
```sql
|
||||
CREATE TABLE content_types (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
slug VARCHAR(64) NOT NULL UNIQUE,
|
||||
description TEXT NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NULL
|
||||
) ENGINE=InnoDB;
|
||||
@@ -0,0 +1,49 @@
|
||||
# Collections V3 Implementation Summary
|
||||
|
||||
Collections v3 is broadly implemented across the Laravel backend, Inertia pages, moderation flows, AI helpers, saved collections, editorial/community discovery surfaces, and scheduled lifecycle publishing.
|
||||
|
||||
Implemented areas:
|
||||
|
||||
- public collection pages, profile collection tabs, featured/trending/editorial/community/seasonal discovery routes
|
||||
- collection studio management for metadata, artworks, collaborators, submissions, discussion, smart rules, layout modules, and editorial ownership
|
||||
- engagement flows for likes, follows, saves, shares, comments, and submissions
|
||||
- admin moderation and report handling for collections, collection comments, and collection submissions
|
||||
- AI-assisted curation endpoints for title, summary, cover, grouping, related works, tags, SEO, and smart-rule explanations
|
||||
- lifecycle scheduling through `published_at`, `unpublished_at`, and the `collections:sync-lifecycle` command
|
||||
|
||||
Gap closure completed during this audit pass:
|
||||
|
||||
- added campaign banner metadata support with `banner_text` and `spotlight_style`
|
||||
- exposed `event_key`, `season_key`, `banner_text`, and `spotlight_style` in collection management payloads and UI
|
||||
- rendered spotlight banner metadata on the public collection hero
|
||||
- added focused feature coverage for campaign metadata persistence and rendering
|
||||
|
||||
Remaining work should be incremental polish rather than foundational implementation.# Collections v3 Implementation Summary
|
||||
|
||||
## Implemented
|
||||
|
||||
- Collaboration roles for owner, editor, contributor, and viewer
|
||||
- Invite flow with pending, accepted, and declined or revoked handling
|
||||
- Community collections with submission, approval, rejection, and withdrawal flows
|
||||
- Collection saves distinct from likes and follows, including a saved collections page
|
||||
- Lightweight collection comments with deletion and reporting support
|
||||
- Public discovery surfaces for featured, trending, editorial, community, and seasonal collections
|
||||
- Homepage collection surfacing for featured or trending, editorial, community, and recent collection strips
|
||||
- Editorial and community collection types with publish and unpublish windows, event metadata, and badge labels
|
||||
- AI-assisted collection helpers for title, summary, cover, grouping, related artworks, tags, SEO description, smart-rule explanations, split suggestions, and merge or spin-out ideas
|
||||
- Related collection recommendations using bounded heuristic scoring
|
||||
- Reporting support for collections, collection comments, and collection submissions
|
||||
- Admin moderation actions for collection status changes, interaction toggles, unfeature actions, and collaborator removal
|
||||
- Tabbed collection studio layout for details, artworks, members, submissions, settings, discussion, AI suggestions, and moderation
|
||||
- Focused feature coverage for core permissions, discovery, AI endpoints, saves, submissions, comments, recommendations, and reporting
|
||||
|
||||
## Not Fully Implemented Yet
|
||||
|
||||
- Distinct invite lifecycle states for expired and declined as first-class states in the schema
|
||||
- Homepage AI-curated or personalized collection modules beyond the new curated strips
|
||||
- Expanded browser or end-to-end coverage for collaborative management flows
|
||||
|
||||
## Validation Snapshot
|
||||
|
||||
- Focused suite: `php artisan test tests/Feature/Collections/CollectionsFeatureTest.php`
|
||||
- Current status: `42 passed (200 assertions)`
|
||||
@@ -0,0 +1,118 @@
|
||||
# Collections v4 Implementation Summary
|
||||
|
||||
## Scope completed
|
||||
|
||||
- Added collection lifecycle, premium presentation, series, campaign, analytics, saved-library, and staff surface foundations on the existing collections stack.
|
||||
- Reused the current Laravel + Inertia collections architecture rather than introducing a parallel system.
|
||||
- Kept public safety constraints intact by continuing to route recommendations and surfaces through public-only collection queries.
|
||||
|
||||
## Backend
|
||||
|
||||
- v4 schema is backed by the collections platform migration that adds lifecycle, scoring, presentation, series, and campaign fields plus supporting tables for:
|
||||
- collection history
|
||||
- collection daily stats
|
||||
- collection surface definitions
|
||||
- collection surface placements
|
||||
- collection saved lists and list items
|
||||
- Fixed the attachment history regression in `CollectionService` so artwork attach actions correctly record the acting owner.
|
||||
- Aligned collection surface placement storage with the actual database schema by persisting:
|
||||
- `placement_type`
|
||||
- `campaign_key`
|
||||
- `notes`
|
||||
- `created_by_user_id`
|
||||
- Added `automatic` surface mode support so rule-driven surfaces can resolve purely from eligible public collections without depending on manual placements.
|
||||
- Broadened automatic surface rules so staff can filter by owner, presentation style, theme token, collaboration mode, score thresholds, analytics/commercial flags, and explicit include/exclude collection IDs.
|
||||
- Added definition-level surface scheduling and fallback behavior so staff surfaces can activate on schedule and hand off cleanly to fallback keys when inactive or empty.
|
||||
- Added staff surface cleanup support so placements can be deleted directly, definitions can be deleted once their placements are cleared, and overlapping active placement windows can be flagged for staff review.
|
||||
- Preserved `saved_at` timestamps in saved collection queries so v4 saved-library sorting works consistently.
|
||||
- Added dedicated owner workflow endpoints for presentation, campaign, and lifecycle updates so the v4 API surface matches the studio’s scoped editing responsibilities instead of relying only on the generic collection update route.
|
||||
- Added manual linked-collection support with persisted ordering, owner studio controls, and public-page rendering that prioritizes curated links while still filtering out non-public targets.
|
||||
- Added first-class series title and description metadata so series landing pages and in-page sequence callouts no longer have to infer copy from keys or campaign labels.
|
||||
|
||||
## Owner and staff UI
|
||||
|
||||
- Added missing Inertia pages:
|
||||
- `CollectionDashboard`
|
||||
- `CollectionAnalytics`
|
||||
- `CollectionHistory`
|
||||
- `CollectionStaffSurfaces`
|
||||
- Expanded the staff surfaces studio so definitions and placements can both be hydrated back into the form and updated through explicit staff routes.
|
||||
- Added staff surface deletion controls plus conflict callouts for overlapping scheduled placements on the same surface.
|
||||
- Expanded staff surface rule guidance so broader automatic filters and `quality_score` ranking are discoverable from the studio.
|
||||
- Expanded the staff surfaces studio to manage definition-level activation windows and fallback surface keys.
|
||||
- Added batch editorial tooling to the staff surfaces studio so staff can preview and apply campaign metadata updates across multiple collections while optionally planning surface placements in one pass.
|
||||
- Upgraded the collection manage studio to expose v4 metadata and insight links:
|
||||
- lifecycle state
|
||||
- analytics toggle
|
||||
- presentation style and emphasis
|
||||
- theme token
|
||||
- series key and order
|
||||
- campaign key and label
|
||||
- series title and description
|
||||
- sponsorship and partner labels
|
||||
- monetization and brand-safety notes
|
||||
- archive and expiry scheduling
|
||||
- AI quality review output
|
||||
- Added linked-collection controls in the manage studio so owners can curate manual related strips alongside series and campaign metadata.
|
||||
- Added dashboard, analytics, history, and staff-surface shortcuts directly in the collection studio.
|
||||
|
||||
## Public and saved-library UI
|
||||
|
||||
- Upgraded the public collection page with:
|
||||
- owner-only analytics/history shortcuts
|
||||
- campaign and series badges
|
||||
- premium presentation and score callouts
|
||||
- intro-block and featured-artworks presentation toggles driven by persisted layout modules
|
||||
- series navigation and sibling collection links
|
||||
- series titles and descriptions where owners provide them
|
||||
- manual linked collections merged ahead of recommendation-driven related collections
|
||||
- Added a dedicated public series landing route so related collection sequences can be browsed as ordered standalone pages.
|
||||
- Rebuilt the saved collections page into a v4 library surface with:
|
||||
- saved-list creation
|
||||
- add-to-list actions
|
||||
- dedicated saved-list detail routes
|
||||
- saved-list order preservation on dedicated list pages
|
||||
- saved-list reorder actions for active lists
|
||||
- remove-from-list actions
|
||||
- unsave cleanup that also removes related saved-list items
|
||||
- filter and sort controls
|
||||
- recommended next collections
|
||||
|
||||
## Testing added
|
||||
|
||||
- Added focused feature coverage for:
|
||||
- premium presentation layout modules hiding intro copy while enabling a featured-artworks strip on public collection pages
|
||||
- staff surface placement creation with v4 placement fields
|
||||
- staff surface definition and placement updates
|
||||
- automatic surface resolution respecting public eligibility rules
|
||||
- broader automatic surface filtering using owner, presentation, score, and include/exclude rules
|
||||
- surface definition fallback behavior when scheduled windows are inactive or a surface resolves no items
|
||||
- placement assignment history entries being recorded for affected collections
|
||||
- deleting staff surface placements while recording placement removal history
|
||||
- preventing surface definition deletion until attached placements are cleared
|
||||
- flagging overlapping active placement windows as staff-visible conflicts
|
||||
- previewing batch editorial campaign and placement changes without persisting them
|
||||
- applying batch editorial campaign metadata while skipping ineligible public-surface placements safely
|
||||
- public series landing pages honoring series order while excluding private and restricted entries
|
||||
- series title and description metadata flowing through series landing pages and collection page sequence callouts
|
||||
- dashboard summaries counting lifecycle buckets, pending submissions, attention queues, and expiring campaigns from the owning curator's collections only
|
||||
- collection analytics payloads returning totals, range deltas, timeline points, and ranked top artworks for owners
|
||||
- allowing active collection editors to access analytics and history while blocking unrelated viewers
|
||||
- dedicated presentation, campaign, and lifecycle workflow routes persisting the expected scoped collection metadata while preserving owner-only authorization
|
||||
- syncing manual linked collections, hydrating them back into the manage studio, and preventing public pages from leaking private linked targets
|
||||
- saved-list creation and adding saved collections to lists
|
||||
- browsing a saved-list route and removing a collection from that list
|
||||
- preserving saved-list item order on dedicated list routes
|
||||
- reordering saved-list items
|
||||
- unsaving a collection clearing that user's saved-list memberships
|
||||
|
||||
## Verification status
|
||||
|
||||
- The focused Collections v4 feature coverage is passing in `tests/Feature/Collections/CollectionsFeatureTest.php`.
|
||||
- Current verification includes analytics, history, lifecycle, presentation, campaign, series, linked collections, saved-library, recommendation safety, public discovery, surface automation, placements, and staff batch editorial tooling.
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
- Add pagination controls for long history timelines beyond previous/next.
|
||||
- Expand saved-library UX with drag-and-drop reorder flows.
|
||||
- Add richer staff planning ergonomics on top of the current batch editorial tools if editorial operations need more campaign-specific scheduling views later.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Collections v5 Implementation Summary
|
||||
|
||||
## Scope
|
||||
|
||||
Collections v5 extends the existing collections platform with operational metadata, workflow controls, health scoring, search, canonicalization, merge support, programming assignments, and snapshot-based observability.
|
||||
|
||||
## Schema additions
|
||||
|
||||
The collections table now carries v5 state for:
|
||||
|
||||
- workflow and readiness
|
||||
- health state and health flags
|
||||
- canonical and duplicate metadata
|
||||
- program, partner, trust, sponsorship, governance, and experiment metadata
|
||||
- ranking and search boost tiers
|
||||
- health component scores and refresh timestamps
|
||||
- placement eligibility
|
||||
|
||||
Supporting tables were added for:
|
||||
|
||||
- program assignments
|
||||
- daily quality snapshots
|
||||
- recommendation snapshots
|
||||
- merge actions
|
||||
- cross-entity links
|
||||
- saved notes
|
||||
|
||||
## New services
|
||||
|
||||
- CollectionHealthService: evaluates metadata completeness, freshness, engagement, readiness, health state, eligibility, and daily snapshots
|
||||
- CollectionRankingService: computes recommendation tier, ranking bucket, and search boost tier
|
||||
- CollectionWorkflowService: validates workflow transitions and runs quality refresh flows
|
||||
- CollectionSearchService: public and owner/staff search across collection metadata
|
||||
- CollectionCanonicalService: assigns canonical targets safely
|
||||
- CollectionMergeService: merges source collections into canonical targets without affecting artwork ownership
|
||||
- CollectionProgrammingService: manages assignments, previews, eligibility refresh, duplicate scans, and recommendation refreshes
|
||||
- CollectionBackgroundJobService: dispatches queued health, quality, recommendation, and duplicate maintenance jobs
|
||||
- CollectionPartnerProgramService: isolates protected partner/program metadata updates
|
||||
- CollectionObservabilityService: cache keys and collection diagnostics
|
||||
- CollectionLinkService: manages generic creator, story, and category links for collection context modules
|
||||
|
||||
## Route coverage
|
||||
|
||||
Public:
|
||||
|
||||
- GET /collections/search
|
||||
|
||||
Owner/staff:
|
||||
|
||||
- GET /settings/collections/search
|
||||
- GET /settings/collections/{collection}/health
|
||||
- POST /settings/collections/{collection}/workflow
|
||||
- POST /settings/collections/{collection}/quality-refresh
|
||||
- POST /settings/collections/{collection}/canonicalize
|
||||
- POST /settings/collections/{collection}/merge
|
||||
|
||||
Staff programming:
|
||||
|
||||
- GET /staff/collections/programming
|
||||
- POST /staff/collections/programs
|
||||
- PATCH /staff/collections/programs/{program}
|
||||
- POST /staff/collections/surfaces/preview
|
||||
- POST /staff/collections/eligibility/refresh
|
||||
- POST /staff/collections/duplicate-scan
|
||||
- POST /staff/collections/recommendation-refresh
|
||||
|
||||
Scheduled maintenance:
|
||||
|
||||
- php artisan collections:dispatch-maintenance
|
||||
|
||||
## Payload changes
|
||||
|
||||
Collection card and detail payloads now expose v5 metadata so dashboard, search, programming, and health tooling can consume the same core shape without custom adapters.
|
||||
|
||||
## Dashboard changes
|
||||
|
||||
The owner dashboard now includes health-oriented summary counts and warning payloads alongside the existing lifecycle, campaign, analytics, and attention views.
|
||||
|
||||
It also includes an internal search and filtering panel for workflow, lifecycle, health, visibility, and placement readiness.
|
||||
|
||||
## Staff programming UI
|
||||
|
||||
The staff programming route now renders a dedicated programming studio with:
|
||||
|
||||
- assignment management
|
||||
- live program pool preview
|
||||
- queued eligibility diagnostics
|
||||
- queued duplicate scan tools
|
||||
- queued recommendation refresh tools
|
||||
- internal experiment and partner/program governance controls with observability diagnostics for the selected collection, including treatment, placement, ranking, pool-version, ownership, sponsorship, and review metadata
|
||||
- staff merge queue for pending duplicate review and recent merge decisions, including direct canonicalize, merge, and reject actions in the studio
|
||||
|
||||
## Background jobs
|
||||
|
||||
Collections v5 now dispatches queued jobs for:
|
||||
|
||||
- owner quality refreshes
|
||||
- staff eligibility refreshes
|
||||
- staff recommendation refreshes
|
||||
- staff duplicate scans
|
||||
- scheduled maintenance sweeps for stale health, recommendation, and duplicate work
|
||||
|
||||
## Merge review workflow
|
||||
|
||||
Collection management now includes an internal merge-review surface with:
|
||||
|
||||
- duplicate candidate comparison cards
|
||||
- canonical target designation
|
||||
- direct merge actions for manual targets
|
||||
- not-duplicate dismissal that suppresses rejected pairs from future review payloads
|
||||
|
||||
## Saved library changes
|
||||
|
||||
The saved collections area now supports lightweight private notes per saved collection.
|
||||
|
||||
## Cross-entity linking
|
||||
|
||||
Collections can now link to creators, public artworks, published stories, and active categories through a generic entity-link flow exposed in collection management and rendered on the public detail page.
|
||||
|
||||
## Test coverage
|
||||
|
||||
Existing collection feature coverage still passes after v5 integration.
|
||||
|
||||
Additional focused tests now cover:
|
||||
|
||||
- owner-only health access
|
||||
- owner search scoping
|
||||
- protected workflow/programming fields
|
||||
- canonicalization and merge flows
|
||||
- public canonical redirects for canonicalized collections
|
||||
- staff programming assignments and previews
|
||||
- staff programming studio access
|
||||
- public search safety
|
||||
- workflow transition validation
|
||||
- saved collection notes
|
||||
- collection entity links on owner and public pages, including artwork and tag links with public-only filtering
|
||||
- public program landing pages with explicit partner and sponsor surfacing on discovery cards and headers
|
||||
- dashboard search/filter wiring
|
||||
102
.deploy/artwork-evolution-release/docs/deployment.md
Normal file
102
.deploy/artwork-evolution-release/docs/deployment.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Deployment
|
||||
|
||||
This repository uses a Bash-based production deploy flow.
|
||||
|
||||
## Normal deploy
|
||||
|
||||
Run the existing entrypoint:
|
||||
|
||||
```bash
|
||||
bash sync.sh
|
||||
```
|
||||
|
||||
If you launch `bash sync.sh` from WSL against this Windows checkout, the script will automatically run the frontend build with `npm.cmd` on Windows so Rollup/Vite use the correct optional native package set.
|
||||
|
||||
This will:
|
||||
|
||||
- build frontend assets locally with `npm run build`
|
||||
- rsync the application files to production
|
||||
- run `composer install --no-dev` on the server
|
||||
- run `php artisan migrate --force`
|
||||
- run `php artisan optimize:clear`
|
||||
- run `php artisan optimize` to rebuild config, events, routes, and views
|
||||
- warm the guest homepage cache with `php artisan homepage:warm-guest-cache`
|
||||
- warm the post trending cache with `php artisan posts:warm-trending`
|
||||
- restart queue workers with `php artisan queue:restart`
|
||||
- gracefully restart Horizon with `php artisan horizon:terminate`
|
||||
|
||||
## Deploy options
|
||||
|
||||
```bash
|
||||
bash sync.sh --skip-build
|
||||
bash sync.sh --skip-migrate
|
||||
bash sync.sh --no-maintenance
|
||||
```
|
||||
|
||||
Environment overrides:
|
||||
|
||||
```bash
|
||||
REMOTE_SERVER=user@example.com REMOTE_FOLDER=/var/www/app bash sync.sh
|
||||
```
|
||||
|
||||
You can also override the local build command explicitly:
|
||||
|
||||
```bash
|
||||
LOCAL_BUILD_COMMAND='npm run build' bash sync.sh
|
||||
LOCAL_BUILD_COMMAND='pnpm build' bash sync.sh
|
||||
```
|
||||
|
||||
## Replace production database from local
|
||||
|
||||
This is intentionally separate from a normal deploy because it overwrites production data.
|
||||
|
||||
```bash
|
||||
bash scripts/push-db-to-prod.sh --force
|
||||
```
|
||||
|
||||
Or combine it with deploy:
|
||||
|
||||
```bash
|
||||
bash sync.sh --with-db-from=local
|
||||
```
|
||||
|
||||
When run interactively, the deploy script will ask you to confirm the exact remote server and type a confirmation phrase before replacing production data.
|
||||
|
||||
For non-interactive use, pass both confirmations explicitly:
|
||||
|
||||
```bash
|
||||
bash sync.sh --with-db-from=local \
|
||||
--confirm-db-sync-target=klevze@server3.klevze.si \
|
||||
--confirm-db-sync-phrase='replace production db from local'
|
||||
```
|
||||
|
||||
Legacy compatibility still exists for:
|
||||
|
||||
```bash
|
||||
bash sync.sh --with-db --force-db-sync
|
||||
```
|
||||
|
||||
But the safer `--with-db-from=local` flow should be preferred.
|
||||
|
||||
The database sync script will:
|
||||
|
||||
- read local DB credentials from the local `.env`
|
||||
- create a local `mysqldump` export
|
||||
- upload the dump to the production server
|
||||
- create a backup of the current production database under `storage/app/deploy-backups`
|
||||
- import the local dump into the production database
|
||||
- run `php artisan migrate --force` unless `--skip-migrate` is passed
|
||||
|
||||
If you run the deploy from WSL while your local MySQL server is running on Windows with `DB_HOST=127.0.0.1` or `localhost`, the DB sync script will automatically use Windows `mysqldump.exe` so it can still reach the local database.
|
||||
|
||||
You can override the dump command explicitly if needed:
|
||||
|
||||
```bash
|
||||
LOCAL_MYSQLDUMP_COMMAND='mysqldump --host=10.0.0.5 --port=3306 --user=app dbname' bash scripts/push-db-to-prod.sh --force
|
||||
```
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Normal deployments should use `bash sync.sh` without `--with-db`.
|
||||
- Use database replacement only for first-time bootstrap, staging, or an intentional full production reset.
|
||||
- Route caching now runs through `php artisan optimize` in deploy automation; if that starts failing again, fix the route definitions instead of dropping route caching from deploy.
|
||||
@@ -0,0 +1,667 @@
|
||||
# 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.
|
||||
|
||||
This document also covers the v3 AI discovery layer: vision metadata extraction, vector indexing, AI similar-artwork search, reverse image search, and the hybrid feed section controls.
|
||||
|
||||
---
|
||||
|
||||
## 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. [AI Discovery v3](#14-ai-discovery-v3)
|
||||
15. [Operational Runbook](#15-operational-runbook)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
```text
|
||||
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:**
|
||||
|
||||
```text
|
||||
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:**
|
||||
|
||||
```text
|
||||
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
|
||||
|
||||
```text
|
||||
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
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```text
|
||||
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. AI Discovery v3
|
||||
|
||||
### 15.1 Overview
|
||||
|
||||
The v3 layer augments the existing recommendation engine with:
|
||||
|
||||
- CLIP-derived embeddings and tags
|
||||
- BLIP captions
|
||||
- YOLO object detections
|
||||
- vector-gateway similarity search
|
||||
- hybrid feed reranking and section generation
|
||||
|
||||
Primary request paths:
|
||||
|
||||
- `GET /api/art/{id}/similar-ai`
|
||||
- `POST /api/search/image`
|
||||
- `POST /api/uploads/{id}/vision-suggest`
|
||||
|
||||
Primary async jobs:
|
||||
|
||||
- `AutoTagArtworkJob`
|
||||
- `GenerateArtworkEmbeddingJob`
|
||||
- `SyncArtworkVectorIndexJob`
|
||||
- `BackfillArtworkVectorIndexJob`
|
||||
|
||||
### 15.2 Core configuration
|
||||
|
||||
Vision gateway:
|
||||
|
||||
- `VISION_ENABLED`
|
||||
- `VISION_GATEWAY_URL`
|
||||
- `VISION_GATEWAY_TIMEOUT`
|
||||
- `VISION_GATEWAY_CONNECT_TIMEOUT`
|
||||
|
||||
Vector gateway:
|
||||
|
||||
- `VISION_VECTOR_GATEWAY_ENABLED`
|
||||
- `VISION_VECTOR_GATEWAY_URL`
|
||||
- `VISION_VECTOR_GATEWAY_API_KEY`
|
||||
- `VISION_VECTOR_GATEWAY_COLLECTION`
|
||||
- `VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT`
|
||||
- `VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT`
|
||||
|
||||
Hybrid feed:
|
||||
|
||||
- `DISCOVERY_V3_ENABLED`
|
||||
- `DISCOVERY_V3_CACHE_TTL_MINUTES`
|
||||
- `DISCOVERY_V3_VECTOR_SIMILARITY_WEIGHT`
|
||||
- `DISCOVERY_V3_VECTOR_BASE_SCORE`
|
||||
- `DISCOVERY_V3_MAX_SEED_ARTWORKS`
|
||||
- `DISCOVERY_V3_VECTOR_CANDIDATE_POOL`
|
||||
|
||||
AI section sizing:
|
||||
|
||||
- `DISCOVERY_V3_SECTION_SIMILAR_STYLE_LIMIT`
|
||||
- `DISCOVERY_V3_SECTION_YOU_MAY_ALSO_LIKE_LIMIT`
|
||||
- `DISCOVERY_V3_SECTION_VISUALLY_RELATED_LIMIT`
|
||||
|
||||
### 15.3 Behavior notes
|
||||
|
||||
- Upload publish remains non-blocking for AI processing; derivatives can complete and the AI jobs are queued after the upload is finalized.
|
||||
- The synchronous `vision-suggest` endpoint is only for immediate upload-step prefill and does not replace the queued persistence path.
|
||||
- `similar-ai` and reverse image search return vector-gateway results only when the gateway is configured; otherwise they fail closed with explicit JSON reasons.
|
||||
- Discovery sections are now tunable from config rather than fixed in code, which makes production adjustments safe without service edits.
|
||||
|
||||
---
|
||||
|
||||
## 15. 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;
|
||||
```
|
||||
123
.deploy/artwork-evolution-release/docs/feed-rollout-runbook.md
Normal file
123
.deploy/artwork-evolution-release/docs/feed-rollout-runbook.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Feed Rollout Runbook (clip-cosine-v2, prod set 1)
|
||||
|
||||
## Scope
|
||||
|
||||
- Candidate: `clip-cosine-v2` with weights `w1=0.52, w2=0.23, w3=0.15, w4=0.10`
|
||||
- Baseline: `clip-cosine-v1`
|
||||
- Rollout gates: `10% -> 50% -> 100%`
|
||||
- Temporary policy: `save_rate` is informational only until save-event schema reliability is confirmed in production.
|
||||
|
||||
## Pre-flight checks
|
||||
|
||||
1. Confirm config values:
|
||||
- `DISCOVERY_ROLLOUT_ENABLED=true`
|
||||
- `DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION=clip-cosine-v1`
|
||||
- `DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION=clip-cosine-v2`
|
||||
- `DISCOVERY_ROLLOUT_ACTIVE_GATE=g10`
|
||||
- `DISCOVERY_FORCE_ALGO_VERSION` is empty
|
||||
2. Confirm candidate weights are active in `config/discovery.php` and env overrides.
|
||||
3. Confirm ingestion health for discovery events:
|
||||
- `event_id` populated for all new events
|
||||
- `favorite` and `download` events present in `user_discovery_events`
|
||||
4. Run daily aggregation:
|
||||
- `php artisan analytics:aggregate-feed --date=YYYY-MM-DD`
|
||||
|
||||
## Gate progression
|
||||
|
||||
### Gate 1: 10%
|
||||
|
||||
- Set: `DISCOVERY_ROLLOUT_ACTIVE_GATE=g10`
|
||||
- Observe for at least 2-3 days with minimum sample volume.
|
||||
- Required checks:
|
||||
- CTR delta vs baseline
|
||||
- Long-dwell-share delta vs baseline
|
||||
- Diversity concentration delta vs baseline
|
||||
- Save-rate trend (informational only)
|
||||
|
||||
Promote to 50% only if no rollback trigger fires and no persistent warning trend is present.
|
||||
|
||||
### Gate 2: 50%
|
||||
|
||||
- Set: `DISCOVERY_ROLLOUT_ACTIVE_GATE=g50`
|
||||
- Observe for 3-5 days with stable daily traffic.
|
||||
- Apply same checks and thresholds.
|
||||
|
||||
Promote to 100% only with at least 2 consecutive healthy days.
|
||||
|
||||
### Gate 3: 100%
|
||||
|
||||
- Set: `DISCOVERY_ROLLOUT_ACTIVE_GATE=g100`
|
||||
- Keep baseline available for rapid rollback via force toggle.
|
||||
|
||||
## Monitoring thresholds (candidate vs baseline)
|
||||
|
||||
- CTR:
|
||||
- Warning: drop >= 3%
|
||||
- Rollback: drop >= 5% (or >= 10% in a single severe window)
|
||||
- Long dwell share (`(dwell_30_120 + dwell_120_plus) / clicks`):
|
||||
- Warning: drop >= 4%
|
||||
- Rollback: drop >= 8% (or >= 12% in a single severe window)
|
||||
- Diversity concentration (e.g. top-author/top-category share, near-duplicate concentration):
|
||||
- Warning: rise >= 10%
|
||||
- Rollback: rise >= 15%
|
||||
|
||||
## Rollback actions
|
||||
|
||||
### Immediate rollback (fastest)
|
||||
|
||||
- Set `DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1`
|
||||
- Reload config/cache as needed in your deployment flow.
|
||||
- Verify feed responses show `meta.algo_version=clip-cosine-v1`.
|
||||
|
||||
### Standard rollback
|
||||
|
||||
- Set `DISCOVERY_ROLLOUT_ACTIVE_GATE=g10` (or disable rollout)
|
||||
- Keep candidate enabled only for controlled validation traffic.
|
||||
|
||||
## Save-event schema note and fix
|
||||
|
||||
Observed issue class in mixed environments: save-event writes can fail if discovery event schema differs from code expectations (e.g., `meta`/`metadata` drift, required `event_id`).
|
||||
|
||||
Implemented fix path:
|
||||
|
||||
- Ingestion now always writes `event_id` and inserts schema-aware metadata (`meta` if present, otherwise `metadata` if present).
|
||||
- Keep `DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true` until production confirms stable save-event ingestion.
|
||||
|
||||
Validation query examples:
|
||||
|
||||
- Save events by day:
|
||||
- `SELECT event_date, COUNT(*) FROM user_discovery_events WHERE event_type IN ('favorite','download') GROUP BY event_date ORDER BY event_date DESC;`
|
||||
- Null/empty event id check:
|
||||
- `SELECT COUNT(*) FROM user_discovery_events WHERE event_id IS NULL OR event_id = '';`
|
||||
|
||||
## Daily operator checklist
|
||||
|
||||
1. Run feed aggregation for the previous day.
|
||||
2. Run evaluator and compare commands:
|
||||
- `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD --json`
|
||||
- `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD --json`
|
||||
3. Record deltas for CTR, long_dwell_share, diversity concentration.
|
||||
4. Record save_rate as informational only.
|
||||
5. Decide: hold, promote gate, or rollback.
|
||||
|
||||
## First 24h verification checklist
|
||||
|
||||
1. Confirm rollout activation and gate state:
|
||||
- `DISCOVERY_ROLLOUT_ENABLED=true`
|
||||
- `DISCOVERY_ROLLOUT_ACTIVE_GATE=g10`
|
||||
- `DISCOVERY_FORCE_ALGO_VERSION` empty
|
||||
2. Verify both algos are receiving traffic in analytics:
|
||||
- candidate (`clip-cosine-v2`) should be near 10% share (allow normal variance)
|
||||
- baseline (`clip-cosine-v1`) remains dominant
|
||||
3. Run aggregation/evaluation at least twice in first day (midday + end-of-day):
|
||||
- `php artisan analytics:aggregate-feed --date=YYYY-MM-DD`
|
||||
- `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD --json`
|
||||
- `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD --json`
|
||||
4. Check guardrails:
|
||||
- CTR drop < rollback threshold
|
||||
- long_dwell_share drop < rollback threshold
|
||||
- diversity concentration rise < rollback threshold
|
||||
5. Check save-event ingestion health:
|
||||
- save events (`favorite`,`download`) are arriving in `user_discovery_events`
|
||||
- `event_id` is always populated
|
||||
6. If any rollback trigger is breached, apply emergency rollback preset immediately.
|
||||
208
.deploy/artwork-evolution-release/docs/forum-bot-protection.md
Normal file
208
.deploy/artwork-evolution-release/docs/forum-bot-protection.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Forum Bot Protection
|
||||
|
||||
This document describes the production anti-bot stack protecting forum, auth, profile, and selected API write actions.
|
||||
|
||||
## Scope
|
||||
|
||||
Primary implementation lives in:
|
||||
|
||||
- `config/forum_bot_protection.php`
|
||||
- `packages/klevze/Plugins/Forum/Services/Security`
|
||||
- `app/Http/Middleware/ForumBotProtectionMiddleware.php`
|
||||
- `packages/klevze/Plugins/Forum/Console/ForumBotScanCommand.php`
|
||||
- `packages/klevze/Plugins/Forum/Jobs/BotActivityMonitor.php`
|
||||
|
||||
Protected actions currently include:
|
||||
|
||||
- registration
|
||||
- login
|
||||
- forum topic create
|
||||
- forum reply create
|
||||
- forum post update
|
||||
- profile update
|
||||
- selected API write routes
|
||||
|
||||
## Detection Layers
|
||||
|
||||
Risk scoring combines multiple signals:
|
||||
|
||||
- honeypot hits
|
||||
- browser and device fingerprints
|
||||
- repeated content and spam phrase analysis
|
||||
- account age and action burst behavior
|
||||
- proxy, Tor, and blacklist checks
|
||||
- provider and datacenter CIDR range checks
|
||||
- account farm heuristics across IP and fingerprint reuse
|
||||
|
||||
The score is interpreted through `config/forum_bot_protection.php`:
|
||||
|
||||
- `allow`
|
||||
- `log`
|
||||
- `captcha`
|
||||
- `moderate`
|
||||
- `block`
|
||||
|
||||
## Persistence
|
||||
|
||||
Bot activity is stored in:
|
||||
|
||||
- `forum_bot_logs`
|
||||
- `forum_bot_ip_blacklist`
|
||||
- `forum_bot_device_fingerprints`
|
||||
- `forum_bot_behavior_profiles`
|
||||
|
||||
User records also carry:
|
||||
|
||||
- `bot_risk_score`
|
||||
- `bot_flags`
|
||||
- `last_bot_activity_at`
|
||||
|
||||
## Captcha Escalation
|
||||
|
||||
When a request risk score reaches the configured captcha threshold, middleware requires a provider-backed challenge before allowing the action.
|
||||
|
||||
Provider selection:
|
||||
|
||||
- `FORUM_BOT_CAPTCHA_PROVIDER=turnstile`
|
||||
- `FORUM_BOT_CAPTCHA_PROVIDER=recaptcha`
|
||||
- `FORUM_BOT_CAPTCHA_PROVIDER=hcaptcha`
|
||||
|
||||
Optional request input override:
|
||||
|
||||
- `FORUM_BOT_CAPTCHA_INPUT`
|
||||
|
||||
Supported provider environment keys:
|
||||
|
||||
### Turnstile
|
||||
|
||||
- `TURNSTILE_SITE_KEY`
|
||||
- `TURNSTILE_SECRET_KEY`
|
||||
- `TURNSTILE_SCRIPT_URL`
|
||||
- `TURNSTILE_VERIFY_URL`
|
||||
|
||||
### reCAPTCHA
|
||||
|
||||
- `RECAPTCHA_ENABLED`
|
||||
- `RECAPTCHA_SITE_KEY`
|
||||
- `RECAPTCHA_SECRET_KEY`
|
||||
- `RECAPTCHA_SCRIPT_URL`
|
||||
- `RECAPTCHA_VERIFY_URL`
|
||||
|
||||
### hCaptcha
|
||||
|
||||
- `HCAPTCHA_ENABLED`
|
||||
- `HCAPTCHA_SITE_KEY`
|
||||
- `HCAPTCHA_SECRET_KEY`
|
||||
- `HCAPTCHA_SCRIPT_URL`
|
||||
- `HCAPTCHA_VERIFY_URL`
|
||||
|
||||
If the selected provider is missing required keys, captcha escalation is effectively disabled and high-risk requests will continue through the non-captcha anti-bot path.
|
||||
|
||||
## Origin Header Setup
|
||||
|
||||
Geo-behavior scoring only activates when the origin receives a trusted two-letter country header. The current analyzer checks these headers in order:
|
||||
|
||||
- `CF-IPCountry`
|
||||
- `CloudFront-Viewer-Country`
|
||||
- `X-Country-Code`
|
||||
- `X-App-Country-Code`
|
||||
|
||||
Recommended production setup:
|
||||
|
||||
### Cloudflare
|
||||
|
||||
- If you only need country detection: Cloudflare Dashboard → `Network` → turn `IP Geolocation` on.
|
||||
- If you want the broader location header set: Cloudflare Dashboard → `Rules` → `Managed Transforms` → enable `Add visitor location headers`.
|
||||
- The origin header used by this app is `CF-IPCountry`.
|
||||
|
||||
### Amazon CloudFront
|
||||
|
||||
- Edit the distribution behavior used for the app origin.
|
||||
- Attach an origin request policy that includes geolocation headers, or create a custom origin request policy that forwards `CloudFront-Viewer-Country`.
|
||||
- If you cache on that behavior and want cache variation by forwarded headers, ensure the paired cache policy is compatible with the origin request policy you choose.
|
||||
|
||||
### Reverse Proxy / Load Balancer
|
||||
|
||||
- Pass the CDN country header through unchanged to PHP-FPM / Laravel.
|
||||
- For Nginx, avoid clearing the header and explicitly preserve it if you normalize upstream headers, for example: `proxy_set_header CF-IPCountry $http_cf_ipcountry;` or `proxy_set_header CloudFront-Viewer-Country $http_cloudfront_viewer_country;`.
|
||||
- If you terminate the CDN header at the proxy and want a normalized application header instead, map it to `X-Country-Code` and keep the value as a two-letter ISO country code.
|
||||
|
||||
Validation:
|
||||
|
||||
- Send a request through the real edge and confirm the header is visible in Laravel request headers.
|
||||
- Check that a login event stored in `forum_bot_logs.metadata.country_code` contains the expected country code.
|
||||
|
||||
## IP Range Configuration
|
||||
|
||||
IP reputation supports three types of network lists in `config/forum_bot_protection.php`:
|
||||
|
||||
- `known_proxies`: exact IPs or CIDRs for proxy and VPN ranges
|
||||
- `datacenter_ranges`: generic datacenter or hosting CIDRs
|
||||
- `provider_ranges`: provider-specific buckets such as `aws`, `azure`, `gcp`, `digitalocean`, `hetzner`, and `ovh`
|
||||
|
||||
All three lists accept either exact IP strings or CIDR notation.
|
||||
|
||||
Example:
|
||||
|
||||
```php
|
||||
'ip' => [
|
||||
'known_proxies' => ['198.51.100.0/24'],
|
||||
'datacenter_ranges' => ['203.0.113.0/24'],
|
||||
'provider_ranges' => [
|
||||
'aws' => ['54.240.0.0/12'],
|
||||
'hetzner' => ['88.198.0.0/16'],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
Operational guidance:
|
||||
|
||||
- keep provider ranges in the named `provider_ranges` buckets so the control panel can show per-provider coverage counts
|
||||
- populate ranges only from provider-owned feeds or other trusted sources you maintain internally
|
||||
- after changing CIDR lists, clear cache if you need immediate effect on hot IPs
|
||||
|
||||
## Queue and Scheduling
|
||||
|
||||
Recent activity scanning runs through:
|
||||
|
||||
- command: `php artisan forum:bot-scan`
|
||||
- queued job: `BotActivityMonitor`
|
||||
- schedule: every 5 minutes in `routes/console.php`
|
||||
|
||||
Default command behavior dispatches the monitor job onto the configured queue. Use `--sync` for inline execution.
|
||||
|
||||
## Admin Operations
|
||||
|
||||
Control panel screen:
|
||||
|
||||
- route: `admin.forum.security.bot-protection.main`
|
||||
|
||||
Available actions:
|
||||
|
||||
- review recent bot events
|
||||
- inspect suspicious users
|
||||
- inspect high-risk fingerprints
|
||||
- inspect recent rate-limit violations and their limiter metadata
|
||||
- manually blacklist IPs
|
||||
- approve or ban flagged users
|
||||
- confirm current captcha provider, threshold, and required env keys
|
||||
- confirm configured proxy, datacenter, tor, and provider CIDR coverage counts
|
||||
- filter analytics by time window and action
|
||||
- export recent bot events as CSV
|
||||
- export top bot reasons as JSON
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Useful commands:
|
||||
|
||||
- `php artisan forum:bot-scan --help`
|
||||
- `php artisan forum:bot-scan --sync --minutes=5`
|
||||
- `php artisan route:list --name=admin.forum.security.bot-protection.main`
|
||||
- `npm run build`
|
||||
|
||||
Quick runtime checks:
|
||||
|
||||
- confirm new bot events land in `forum_bot_logs`
|
||||
- confirm fingerprints land in `forum_bot_device_fingerprints`
|
||||
- confirm the jobs table contains `BotActivityMonitor` after `forum:bot-scan`
|
||||
- confirm the control panel shows the expected captcha provider and action list
|
||||
@@ -0,0 +1,161 @@
|
||||
# Legacy Routes Inventory
|
||||
|
||||
Source analyzed: routes/legacy.php
|
||||
Date: 2026-03-12
|
||||
|
||||
This list includes active (non-commented) routes still defined in the legacy compatibility layer.
|
||||
|
||||
Companion execution guide: [docs/legacy-routes-removal-checklist.md](docs/legacy-routes-removal-checklist.md)
|
||||
|
||||
## Avatar and Artwork
|
||||
|
||||
| Method | Path | Route Name | Handler / Target |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | /avatar/{id}/{name?} | legacy.avatar | LegacyAvatarController@show |
|
||||
| GET, POST | /art/{id}/comment | - | ArtController@show |
|
||||
|
||||
## Categories and Browse
|
||||
|
||||
| Method | Path | Route Name | Handler / Target |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | /sections | sections | 301 -> /categories |
|
||||
| GET | /browse-categories | browse.categories | 301 -> /categories |
|
||||
| GET | /{group}/{slug}/{id} | legacy.category.short | CategoryRedirectController |
|
||||
| GET | /category/{group}/{slug?}/{id?} | legacy.category | CategoryRedirectController |
|
||||
| GET | /browse | legacy.browse | 301 -> /explore |
|
||||
| GET | /featured-artworks | legacy.featured_artworks | 301 -> /featured |
|
||||
| GET | /daily-uploads | legacy.daily_uploads | 301 -> /uploads/daily |
|
||||
|
||||
## Community and Listings
|
||||
|
||||
| Method | Path | Route Name | Handler / Target |
|
||||
| --- | --- | --- | --- |
|
||||
| GET, POST | /chat | - | 301 -> /messages |
|
||||
| GET, POST | /community/chat | community.chat | 301 -> /messages |
|
||||
| GET | /latest | legacy.latest | 301 -> /uploads/latest |
|
||||
| GET | /authors/top | authors.top | 301 -> /creators/top |
|
||||
| GET | /latest-artworks | legacy.latest_artworks | 301 -> /discover/fresh |
|
||||
| GET | /latest-comments | legacy.latest_comments | 301 -> /community/activity |
|
||||
| GET | /comments/latest | comments.latest | 301 -> /community/activity |
|
||||
| GET | /today-in-history | legacy.today_in_history | 301 -> /discover/on-this-day |
|
||||
| GET | /today-downloads | legacy.today_downloads | 301 -> /downloads/today |
|
||||
| GET | /monthly-commentators | legacy.monthly_commentators | 301 -> /comments/monthly |
|
||||
| GET | /members | legacy.members | 301 -> /creators/top |
|
||||
| GET | /top-favourites | legacy.top_favourites | 301 -> /discover/top-rated |
|
||||
|
||||
## Legacy Redirect Endpoints
|
||||
|
||||
| Method | Path | Route Name | Handler / Target |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | /top-authors | legacy.top_authors | 301 -> /creators/top |
|
||||
| GET | /interviews | legacy.interviews | 301 -> /stories |
|
||||
| GET | /apply | legacy.apply.redirect | 301 -> /contact |
|
||||
| GET, POST | /bug-report | bug-report.redirect | 301 -> /contact |
|
||||
|
||||
## Auth-Only Legacy Routes
|
||||
|
||||
| Method | Path | Route Name | Handler / Target |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | /mybuddies.php | legacy.mybuddies.php | 301 -> dashboard.following |
|
||||
| GET | /mybuddies | legacy.mybuddies | 301 -> dashboard.following |
|
||||
| DELETE | /mybuddies/{id} | legacy.mybuddies.delete | 302 -> dashboard.following |
|
||||
| GET | /buddies.php | legacy.buddies.php | 301 -> dashboard.followers |
|
||||
| GET | /buddies | legacy.buddies | 301 -> dashboard.followers |
|
||||
| GET | /statistics | legacy.statistics | StatisticsController@index |
|
||||
| GET, POST | /user | legacy.user.redirect | 301 -> dashboard.profile |
|
||||
| GET | /recieved-comments | legacy.received_comments | 301 -> dashboard.comments.received |
|
||||
| GET | /received-comments | legacy.received_comments.corrected | 301 -> dashboard.comments.received |
|
||||
|
||||
## Favourites, Gallery, and Profile Legacy URLs
|
||||
|
||||
| Method | Path | Route Name | Handler / Target |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | /favourites/{id?}/{username?} | legacy.favourites | FavouritesController@index |
|
||||
| POST | /favourites/{userId}/delete/{artworkId} | legacy.favourites.delete | FavouritesController@destroy |
|
||||
| GET | /gallery/{id}/{username?} | legacy.gallery | 301 -> /@username/gallery via GalleryController |
|
||||
| GET | /user/{username} | legacy.user.profile | ProfileController@legacyByUsername |
|
||||
| GET | /profile/{id}/{username?} | legacy.profile.id | ProfileController@legacyById |
|
||||
| GET | /profile/{username} | legacy.profile | ProfileController@legacyByUsername |
|
||||
|
||||
## Legacy RSS Feed URLs
|
||||
|
||||
| Method | Path | Route Name | Handler / Target |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | /rss/latest-uploads.xml | rss.uploads | RssFeedController@latestUploads |
|
||||
| GET | /rss/latest-skins.xml | rss.skins | RssFeedController@latestSkins |
|
||||
| GET | /rss/latest-wallpapers.xml | rss.wallpapers | RssFeedController@latestWallpapers |
|
||||
| GET | /rss/latest-photos.xml | rss.photos | RssFeedController@latestPhotos |
|
||||
|
||||
## Notes
|
||||
|
||||
- Commented-out legacy blocks (manage pages, received-comments, forum.php redirect) are excluded from this active inventory.
|
||||
- Several routes are already transitional redirects; these are good early candidates for removal once logs show no meaningful traffic.
|
||||
|
||||
## Deprecation Priority
|
||||
|
||||
### Phase 1: Safe To Remove First (redirect-only)
|
||||
|
||||
These routes already do permanent redirects and typically carry the lowest removal risk once access logs confirm near-zero traffic.
|
||||
|
||||
- /top-authors
|
||||
- /interviews
|
||||
- /apply
|
||||
- /bug-report
|
||||
- /browse-redirect
|
||||
- /wallpapers-redirect
|
||||
|
||||
### Phase 2: Low-Risk Alias Pairs (keep canonical, remove duplicates)
|
||||
|
||||
For each pair, keep the cleaner canonical URL and remove the older alias after monitoring.
|
||||
|
||||
- Keep /featured-artworks, remove /featured (or vice versa; pick one canonical)
|
||||
- Keep /uploads/latest, remove /latest
|
||||
- Keep /comments/latest, remove /latest-comments
|
||||
- Keep /downloads/today, remove /today-downloads
|
||||
- Keep /comments/monthly, remove /monthly-commentators
|
||||
|
||||
### Phase 3: Needs Migration Plan (controller-backed legacy behavior)
|
||||
|
||||
These endpoints are not simple redirects and may have old templates, old query semantics, or SEO dependencies.
|
||||
|
||||
- /browse
|
||||
- /category/{group}/{slug?}/{id?}
|
||||
- /browse-categories
|
||||
- /daily-uploads
|
||||
- /members
|
||||
- /members/photos
|
||||
- /authors/top
|
||||
- /top-favourites
|
||||
- /chat
|
||||
- /chat_post
|
||||
- /favourites/{id?}/{username?}
|
||||
- /favourites/{userId}/delete/{artworkId}
|
||||
|
||||
### Phase 4: Legacy Profile URL Surface (requires 301 mapping checks)
|
||||
|
||||
These should eventually collapse to canonical @username/profile routes, but require careful redirect and username-edge-case validation.
|
||||
|
||||
- /user/{username}
|
||||
- /profile/{id}/{username?}
|
||||
- /profile/{username}
|
||||
- /user
|
||||
|
||||
### Phase 5: Keep For Compatibility (unless external consumers are migrated)
|
||||
|
||||
These are frequently consumed by feed readers or old clients and should be retired only with explicit migration communication.
|
||||
|
||||
- /rss/latest-uploads.xml
|
||||
- /rss/latest-skins.xml
|
||||
- /rss/latest-wallpapers.xml
|
||||
- /rss/latest-photos.xml
|
||||
|
||||
### Special Case: Auth-only legacy utility pages
|
||||
|
||||
Evaluate usage with auth logs before deprecating. They are likely niche but can still be bookmarked by long-time users.
|
||||
|
||||
- /mybuddies.php
|
||||
- /mybuddies
|
||||
- /mybuddies/{id}
|
||||
- /buddies.php
|
||||
- /buddies
|
||||
- /statistics
|
||||
@@ -0,0 +1,137 @@
|
||||
# Legacy Routes Removal Checklist
|
||||
|
||||
Date: 2026-03-12
|
||||
Scope: staged retirement of routes listed in docs/legacy-routes-inventory.md
|
||||
|
||||
## Global Preflight (run before each phase)
|
||||
|
||||
- Confirm canonical replacement routes exist and are healthy.
|
||||
- Snapshot current route map:
|
||||
- `php artisan route:list --path=legacy`
|
||||
- `php artisan route:list --json > storage/logs/routes-before-phase.json`
|
||||
- Capture 14-30 day usage for each candidate route from access logs / analytics.
|
||||
- Define rollback rule: restore removed routes immediately if a business-critical endpoint drops or 404 volume spikes.
|
||||
|
||||
## Standard PR Steps (for every phase)
|
||||
|
||||
1. Remove targeted route entries from routes/legacy.php.
|
||||
2. If needed, add explicit 301 redirects to canonical paths in routes/web.php.
|
||||
3. Update route-based tests (e2e fixtures and auth guard lists).
|
||||
4. Run cache clear and verify registration:
|
||||
- `php artisan optimize:clear`
|
||||
- `php artisan route:list --json`
|
||||
5. Add release note entry with removed paths and replacements.
|
||||
|
||||
## SEO Safety Checklist
|
||||
|
||||
- Ensure removed indexed URLs either:
|
||||
- return 301 to final canonical URL, or
|
||||
- return 410 only when intentional and approved.
|
||||
- Keep canonical tags correct on destination pages.
|
||||
- Re-submit updated sitemap if route removals change indexed URLs.
|
||||
- Monitor Search Console coverage and soft-404 reports for 2-4 weeks.
|
||||
|
||||
## Monitoring Checklist (post-deploy)
|
||||
|
||||
- Track 404 count by path prefix for removed routes.
|
||||
- Track 301 volume for newly redirected legacy paths.
|
||||
- Watch top entry pages for traffic drops.
|
||||
- Verify no auth redirect loops were introduced on legacy auth-only pages.
|
||||
|
||||
## Phase-by-Phase Execution
|
||||
|
||||
### Phase 1: Redirect-only endpoints (lowest risk)
|
||||
|
||||
Targets:
|
||||
- /top-authors
|
||||
- /interviews
|
||||
- /apply
|
||||
- /bug-report
|
||||
- /browse-redirect
|
||||
- /wallpapers-redirect
|
||||
|
||||
Phase checks:
|
||||
- Confirm each path still has a valid replacement target.
|
||||
- Validate 301 status and location header for each route.
|
||||
|
||||
### Phase 2: Alias pair cleanup
|
||||
|
||||
Targets:
|
||||
- /featured vs /featured-artworks
|
||||
- /uploads/latest vs /latest
|
||||
- /comments/latest vs /latest-comments
|
||||
- /downloads/today vs /today-downloads
|
||||
- /comments/monthly vs /monthly-commentators
|
||||
|
||||
Phase checks:
|
||||
- Pick one canonical URL per pair.
|
||||
- Keep redirects from removed alias to chosen canonical.
|
||||
- Update internal links to canonical only.
|
||||
|
||||
### Phase 3: Controller-backed legacy pages
|
||||
|
||||
Targets:
|
||||
- /browse
|
||||
- /category/{group}/{slug?}/{id?}
|
||||
- /browse-categories
|
||||
- /daily-uploads
|
||||
- /members
|
||||
- /members/photos
|
||||
- /authors/top
|
||||
- /top-favourites
|
||||
- /today-in-history
|
||||
- /chat
|
||||
- /chat_post
|
||||
- /favourites/{id?}/{username?}
|
||||
- /favourites/{userId}/delete/{artworkId}
|
||||
- /gallery/{id}/{username?}
|
||||
|
||||
Phase checks:
|
||||
- Confirm parity exists in modern endpoints before removal.
|
||||
- Add temporary 301 map for any high-traffic paths.
|
||||
- Validate auth and CSRF behavior for migrated POST endpoints.
|
||||
|
||||
### Phase 4: Profile URL legacy surface
|
||||
|
||||
Targets:
|
||||
- /user/{username}
|
||||
- /profile/{id}/{username?}
|
||||
- /profile/{username}
|
||||
- /user
|
||||
|
||||
Phase checks:
|
||||
- Validate deterministic mapping to /@username.
|
||||
- Handle missing username and renamed accounts gracefully.
|
||||
- Verify no duplicate-content issues remain after redirects.
|
||||
|
||||
### Phase 5: RSS compatibility endpoints (communication required)
|
||||
|
||||
Targets:
|
||||
- /rss/latest-uploads.xml
|
||||
- /rss/latest-skins.xml
|
||||
- /rss/latest-wallpapers.xml
|
||||
- /rss/latest-photos.xml
|
||||
|
||||
Phase checks:
|
||||
- Identify external consumers (feed readers, partner integrations).
|
||||
- Publish migration notice before retirement.
|
||||
- Keep stable replacement feed URLs live before cutover.
|
||||
|
||||
## Fast QA Matrix
|
||||
|
||||
Test each removed or redirected route for:
|
||||
- Anonymous user request
|
||||
- Authenticated user request
|
||||
- Expected status code (301/200/404/410)
|
||||
- Correct final destination URL
|
||||
- No infinite redirect chain
|
||||
|
||||
Suggested command:
|
||||
- `php artisan test --filter=routes`
|
||||
|
||||
## Rollback Playbook
|
||||
|
||||
- Reintroduce removed route definitions from git history.
|
||||
- Clear caches: `php artisan optimize:clear`.
|
||||
- Redeploy hotfix.
|
||||
- Re-run route smoke tests and check access logs.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Nova Cards v3 Audit Checklist
|
||||
|
||||
Audit date: 2026-03-28
|
||||
|
||||
Source of truth reviewed:
|
||||
- `.vscode/agents/skinbase-nova-cards-system_v3.md`
|
||||
- Nova Cards controllers, services, models, routes, React Studio UI, migrations, and feature tests
|
||||
|
||||
Validation status:
|
||||
- Verified with `php artisan test tests/Feature/NovaCards`
|
||||
- Result: 72 passing tests, 489 assertions
|
||||
|
||||
Follow-up roadmap:
|
||||
- `docs/nova-cards-v3-priority-roadmap.md`
|
||||
|
||||
Status legend:
|
||||
- `[x]` Implemented end to end or clearly present
|
||||
- `[-]` Partially implemented or scaffolded
|
||||
- `[ ]` No clear implementation found during audit
|
||||
|
||||
Important note:
|
||||
- The v3 agent file is a roadmap/spec, not a strict shipping checklist. Several sections describe target-state product direction rather than features that are already expected to exist in full.
|
||||
|
||||
## 1. v3 schema versioning and normalization
|
||||
|
||||
- [x] `schema_version` is stored and normalized for v3 cards
|
||||
- [x] New projects normalize to v3 shape
|
||||
- [x] Legacy v1/v2 projects are detected and upgraded
|
||||
- [x] Save flow rewrites cards into the newer project structure
|
||||
- [x] Compatibility behavior is covered by feature tests
|
||||
- [-] Explicit admin rerender/backfill tooling for migrated cards was not found
|
||||
|
||||
Notes:
|
||||
- Implemented via migration, `NovaCardProjectNormalizer`, and v3 tests.
|
||||
|
||||
## 2. Advanced editor architecture improvements
|
||||
|
||||
- [x] Structured project JSON exists with major v3 sections like `canvas`, `background`, `text_blocks`, `frame`, `effects`, `export_preferences`, and `source_context`
|
||||
- [x] Multiple text blocks are supported
|
||||
- [x] Additional body and caption blocks can be added and edited
|
||||
- [x] Layout presets, alignment, position, padding, max width, typography, frame, effects, background modes, overlays, focal position, and packs are exposed in the Studio UI
|
||||
- [x] Background upload works through the draft API
|
||||
- [x] Mobile step-based flow exists for Studio
|
||||
- [x] Version history and version restore exist
|
||||
- [x] Block reordering UI now exists for text blocks
|
||||
- [x] Compare versions UI now exists at a useful summary level
|
||||
- [x] Dedicated quick mode versus advanced mode switch now exists
|
||||
- [-] The editor is structured and block-based, but not a full layer tool
|
||||
- [-] Visibility toggles exist for text blocks through the `enabled` flag, but not as a broader layer system
|
||||
- [ ] Snap guides were not found
|
||||
- [ ] Safe-zone editing helpers beyond stored config were not found
|
||||
- [ ] Lock/unlock controls for editable elements were not found
|
||||
|
||||
Notes:
|
||||
- This area is materially beyond MVP, and the recent Studio usability pass closed the highest-value gaps around quick/full mode, block reorder, and version compare. It still does not match the full “advanced studio” vision in the spec.
|
||||
|
||||
## 3. Creator presets
|
||||
|
||||
- [x] Creator presets table and model exist
|
||||
- [x] Preset CRUD API exists
|
||||
- [x] Presets can be captured from an existing card
|
||||
- [x] Presets can be applied back to a card as project patches
|
||||
- [x] Default preset behavior exists
|
||||
- [x] Studio UI exposes preset browsing, apply, capture, and delete actions
|
||||
- [x] Preset logic is covered by tests
|
||||
|
||||
## 4. Discovery and ranking surfaces
|
||||
|
||||
- [x] Public cards index includes featured, trending, latest, and rising content
|
||||
- [x] Rising service exists and is tested
|
||||
- [x] Related cards service exists and is tested
|
||||
- [x] Remixed cards page exists
|
||||
- [x] Lineage page exists
|
||||
- [x] Challenge listing and challenge detail pages exist
|
||||
- [x] Template packs page exists
|
||||
- [x] Creator page exists
|
||||
- [x] Dedicated style-family feed now exists
|
||||
- [x] Dedicated palette-family feed now exists
|
||||
- [x] Best-remixes discovery surface now exists
|
||||
- [x] Dedicated mood feed now exists through config-backed tag mappings
|
||||
- [x] Dedicated editorial landing page now exists
|
||||
- [x] Dedicated seasonal landing page now exists
|
||||
- [-] Discovery includes several meaningful surfaces, but not the full matrix described by the spec
|
||||
- [ ] Remix-tree browsing surface beyond lineage was not found
|
||||
|
||||
Notes:
|
||||
- Discovery is solid for core launch surfaces, but it is not yet the full “discovery intelligence” product outlined in the spec.
|
||||
|
||||
## 5. Creator identity and profile integration
|
||||
|
||||
- [x] Creator-specific public page exists for cards by user
|
||||
- [x] Creator page now exposes public summary stats and creator highlight cards
|
||||
- [x] Creator page now exposes featured works and featured collections
|
||||
- [x] Top categories and top tags are surfaced on creator pages
|
||||
- [x] Studio analytics page exists for a creator’s own card with views, likes, saves, remixes, shares, downloads, and related counters
|
||||
- [x] Style family and palette family are stored on cards
|
||||
- [-] Creator identity exists at the card/creator listing level, but richer creator-card identity features are not complete
|
||||
- [x] Featured card rail now exists on creator pages as featured works
|
||||
- [x] Featured collection rail now exists on creator pages
|
||||
- [x] Dedicated Nova Cards creator portfolio page now exists
|
||||
- [x] Creator pages now surface signature themes, challenge history, most remixed works, most liked works, remix branch activity, a remix graph summary, creator preference signals, and a recent creator timeline
|
||||
- [-] Deeper creator identity modules are still incomplete beyond the current public portfolio summary; stronger interactive remix graphing and richer persistent preference identity are still missing
|
||||
- [x] Creator spotlight eligibility/featured creators now exist for Nova Cards editorial surfacing
|
||||
|
||||
## 6. Social, remix, collection, challenge, and editorial support
|
||||
|
||||
- [x] Like, favorite, save, remix, duplicate, and challenge submission flows exist
|
||||
- [x] Remix lineage is preserved and visible
|
||||
- [x] Public collection detail page exists
|
||||
- [x] Official collections and admin management for Nova Cards exist
|
||||
- [x] Challenge admin management exists
|
||||
- [x] Challenge entry visibility rules are covered by tests
|
||||
- [x] Best-remixes public surface exists
|
||||
- [x] Nova Cards editorial landing surfaces exist
|
||||
- [x] Seasonal or themed card hubs specific to Nova Cards exist
|
||||
- [x] Featured creators workflow specific to Nova Cards now exists
|
||||
- [-] Editorial and community support is present, but the richer “ecosystem” layer remains incomplete
|
||||
|
||||
## 7. AI assistance hooks and optional UI
|
||||
|
||||
- [x] AI assist endpoint exists
|
||||
- [x] AI assist service exists
|
||||
- [x] Studio UI exposes AI suggestion requests
|
||||
- [x] Suggestions include tags, mood, layout suggestions, and readability fixes
|
||||
- [x] Suggestions are optional and creator-controlled
|
||||
- [-] The AI assist layer appears primarily rule-based/fallback-driven rather than a deep external AI composition system
|
||||
- [ ] More advanced assist actions from the spec, such as “convert to story format” or “create wallpaper version” as explicit AI actions, were not found
|
||||
|
||||
## 8. Export system improvements
|
||||
|
||||
- [x] Export request API exists
|
||||
- [x] Export status polling exists
|
||||
- [x] Export queue job exists
|
||||
- [x] Multiple export formats exist: preview, hires, square, story, wallpaper, and OG
|
||||
- [x] Export permissions are enforced via `allow_export`
|
||||
- [x] Studio UI exposes export requests
|
||||
- [-] Core export foundation is implemented, but richer policy/output features remain open
|
||||
- [ ] Watermark policy controls were not found
|
||||
- [ ] Advanced export preset management beyond format choice was not found
|
||||
|
||||
## 9. Trust, moderation, and rights
|
||||
|
||||
- [x] Card moderation status exists on the model
|
||||
- [x] Reporting supports cards, comments, challenges, and challenge entries
|
||||
- [x] Moderation queue/report summary integration exists
|
||||
- [x] Ownership and remix-related rights flags exist, including `allow_remix`, `allow_background_reuse`, and `allow_export`
|
||||
- [x] Background upload validation is enforced
|
||||
- [x] API throttles and write-protection middleware are wired on key Nova Cards endpoints
|
||||
- [-] Trust/moderation foundations are real, but the full anti-abuse roadmap is not complete
|
||||
- [x] Publish-time duplicate-content heuristic flagging now exists for Nova Cards
|
||||
- [x] Publish-time self-remix-loop heuristic flagging now exists for Nova Cards
|
||||
- [x] Staff admin/reporting surfaces now expose persisted Nova Card publish heuristic reasons
|
||||
- [x] Staff moderation overrides now persist actor/source metadata on Nova Cards
|
||||
- [x] Staff overrides now persist moderation disposition codes like cleared/escalated/rejected
|
||||
- [x] Staff moderation UI now allows selecting custom review dispositions per action
|
||||
- [ ] Challenge branch freeze / remix-branch intervention tooling was not found
|
||||
|
||||
## 10. Backward-compatible migration strategy and rerender tooling
|
||||
|
||||
- [x] Migration for v3 schema additions exists
|
||||
- [x] Legacy project JSON is normalized at load/use time
|
||||
- [x] Older cards remain editable through normalization paths
|
||||
- [x] Tests verify upgrade behavior
|
||||
- [-] Compatibility is present, but operational migration tooling is incomplete
|
||||
- [ ] Admin rerender tool for migrated cards was not found
|
||||
- [ ] Backfill job for schema versions was not found
|
||||
|
||||
## 11. Acceptance criteria summary
|
||||
|
||||
### Studio/editor
|
||||
- [x] Structured multi-block project model exists
|
||||
- [x] Creator presets exist and can be reused
|
||||
- [x] Versioned schema and normalization work
|
||||
- [x] Exports are functional
|
||||
- [x] Mobile Studio flow is usable
|
||||
- [x] Studio usability pass now covers quick/full mode, text-block reorder, and version compare summaries
|
||||
- [-] Full advanced editor capability set from the spec is not complete
|
||||
|
||||
### Social/community
|
||||
- [x] Remix chains are preserved and visible
|
||||
- [x] Collections are integrated into card flows
|
||||
- [x] Challenge flows exist
|
||||
- [-] Stronger creator identity exists only in a limited form
|
||||
|
||||
### Discovery
|
||||
- [x] Rising and related content work
|
||||
- [x] Creator, collection, challenge, and template public surfaces exist
|
||||
- [-] Broader discovery matrix and editorial surfacing are incomplete
|
||||
|
||||
### AI assistance
|
||||
- [x] Optional assistive suggestions exist
|
||||
- [x] Creator remains in control
|
||||
- [-] AI assistance is limited compared with the long-term vision
|
||||
|
||||
### Moderation/trust
|
||||
- [x] Reporting and moderation hooks are in place
|
||||
- [x] Attribution and basic rights controls are preserved
|
||||
- [-] Anti-abuse depth from the spec is incomplete
|
||||
|
||||
### Technical
|
||||
- [x] v1 and v2 content remain compatible through normalization
|
||||
- [x] Schema migration is safe and test-covered
|
||||
- [x] Render/export pipeline supports v3 features currently in use
|
||||
- [-] Future-ready operational tooling is only partially present
|
||||
|
||||
## 12. Bottom line
|
||||
|
||||
- [x] Nova Cards v3 foundation is implemented
|
||||
- [x] The core shipping slice is real: schema versioning, Studio improvements, presets, AI assist, exports, rising, related cards, remix lineage, challenge integration, moderation/reporting, and public browsing
|
||||
- [-] Several roadmap areas are only partially implemented
|
||||
- [ ] The full spec in `.vscode/agents/skinbase-nova-cards-system_v3.md` is not fully implemented end to end
|
||||
|
||||
## 13. Highest-value missing areas
|
||||
|
||||
- [ ] Advanced editor depth beyond the recent usability pass: lock/unlock, snap guides, safe-zone helpers, and broader layer controls
|
||||
- [-] Rich creator identity surfaces: featured works, featured collections, and featured creators now exist, but portfolio depth and creator-history surfaces are still missing
|
||||
- [x] Broader discovery surfaces now include style, palette, mood, best-remix, editorial, and seasonal pages
|
||||
- [-] Stronger editorial tooling exists for cards, collections, challenges, and creators, but broader packaging and workflow depth are still limited
|
||||
- [ ] Operational rerender/backfill tooling for long-term schema evolution
|
||||
@@ -0,0 +1,229 @@
|
||||
# Nova Cards v3 Gap Matrix
|
||||
|
||||
Audit date: 2026-03-28
|
||||
|
||||
Primary spec:
|
||||
- `.vscode/agents/skinbase-nova-cards-system_v3.md`
|
||||
|
||||
Validation baseline:
|
||||
- Targeted verification passed with `php artisan test tests/Feature/NovaCards/NovaCardAdminTest.php tests/Feature/NovaCards/NovaCardPublicPagesTest.php`
|
||||
- Result: 19 passing tests, 194 assertions
|
||||
|
||||
How to read this document:
|
||||
- `Implemented` means there is direct repo evidence for the pillar's core shipping behavior.
|
||||
- `Partial` means meaningful work exists, but the pillar still falls short of the v3 spec's target-state behavior.
|
||||
- `Missing` means no clear implementation was found for that item during this audit.
|
||||
|
||||
Important note:
|
||||
- The v3 agent file is a roadmap-level spec, not a literal definition of what must already be fully shipped. This matrix measures current repo reality against that target state.
|
||||
|
||||
## 1. Advanced studio
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- Structured v3 schema with `canvas`, `background`, `text_blocks`, `frame`, `effects`, `export_preferences`, and `source_context` in `app/Services/NovaCards/NovaCardProjectNormalizer.php`
|
||||
- Stored v3 canvas flags for safe zones and snap guides in `app/Services/NovaCards/NovaCardProjectNormalizer.php`
|
||||
- Quick versus advanced editor modes in `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
- Text-block reorder, enable/disable toggles, remove controls, and multi-block editing in `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
- Version restore and compare-summary UI in `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
- Advanced frame/effect/quote-mark/text-panel controls in `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- No visible snap-guide UI or guide interactions were found; only stored schema flags exist.
|
||||
- No broader lock/unlock controls for editable elements were found.
|
||||
- No full layer list with layer-level controls beyond text-block ordering was found.
|
||||
- No drag-based canvas editing or anchor-point manipulation UI was found.
|
||||
- Safe-zone support appears schema-level, not an exposed editing aid.
|
||||
|
||||
Verdict:
|
||||
- The Studio is materially beyond MVP and has a credible v3 foundation, but it is not yet the full advanced layer/block studio described in the spec.
|
||||
|
||||
## 2. Creator identity
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- Public creator page and dedicated creator portfolio page exist in `app/Http/Controllers/Web/NovaCardsController.php` and `routes/web.php`
|
||||
- Creator summary stats, top categories, top tags, top moods, featured works, featured collections, creator highlights, most remixed works, most liked works, challenge history, signature themes, remix-branch activity, remix graph visualization, creator preference signals, and a recent creator timeline are rendered through `app/Http/Controllers/Web/NovaCardsController.php` and `resources/views/cards/index.blade.php`
|
||||
- Public coverage for featured works, challenge history, signature themes, and creator portfolio depth exists in `tests/Feature/NovaCards/NovaCardPublicPagesTest.php`
|
||||
- Staff-facing featured creator control exists in `app/Http/Controllers/Settings/NovaCardAdminController.php`, `resources/js/Pages/Collection/NovaCardsAdminIndex.jsx`, and `routes/web.php`
|
||||
- Editorial featured-creators surface is wired in `app/Http/Controllers/Web/NovaCardsController.php` and `resources/views/cards/index.blade.php`
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- No richer creator identity controls such as public template favorites or stronger persistent style identity tools were found beyond preference signals, presets, and stored style metadata.
|
||||
- No true interactive remix graph visualization was found beyond the current branch graph summary.
|
||||
|
||||
Verdict:
|
||||
- Creator identity is materially stronger than the older audit suggested, but it still falls short of the spec's broader portfolio, timeline, and persistent identity vision.
|
||||
|
||||
## 3. Social and remix culture
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- Remix lineage is preserved in model/project state and exposed on card pages in `app/Services/NovaCards/NovaCardPresenter.php`, `resources/views/cards/show.blade.php`, and `resources/views/cards/lineage.blade.php`
|
||||
- Dedicated lineage route and controller action exist in `app/Http/Controllers/Web/NovaCardsController.php` and `routes/web.php`
|
||||
- Best-remixes and remixed-card discovery surfaces exist in `app/Http/Controllers/Web/NovaCardsController.php`
|
||||
- Public lineage and remix coverage exists in `tests/Feature/NovaCards/NovaCardPublicPagesTest.php`
|
||||
- Likes, saves, collections, and challenge participation are integrated into Studio and public surfaces in `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- No richer remix-tree browser beyond lineage/family views was found.
|
||||
- No moderation action to freeze a remix branch was found.
|
||||
- No explicit remix-abuse or spam-loop intervention workflow was found.
|
||||
|
||||
Verdict:
|
||||
- Core remix culture is implemented, but deeper moderation and exploration around remix networks remain unfinished.
|
||||
|
||||
## 4. Discovery intelligence
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- Trending, featured, latest, and rising public feeds exist in `app/Http/Controllers/Web/NovaCardsController.php`
|
||||
- Dedicated rising service exists in `app/Services/NovaCards/NovaCardRisingService.php`
|
||||
- Related cards service exists in `app/Services/NovaCards/NovaCardRelatedCardsService.php`
|
||||
- Public mood, style, palette, editorial, seasonal, creator, collection, challenge, and best-remixes surfaces exist in `app/Http/Controllers/Web/NovaCardsController.php`
|
||||
- Targeted public-page coverage exists in `tests/Feature/NovaCards/NovaCardPublicPagesTest.php`
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- The full discovery matrix from the spec is not present; there is no dedicated template family feed, quote-type feed, or broader creator-diversity surfacing layer.
|
||||
- Discovery balance still looks primarily route-by-route rather than a unified discovery engine with stronger editorial/algorithmic blending.
|
||||
- No richer creator diversity or new-creator exposure tuning surface was found beyond the rising service and editorial featuring.
|
||||
|
||||
Verdict:
|
||||
- Discovery is already broad enough to feel like a real product surface, but it is not yet the full discovery-intelligence platform described in v3.
|
||||
|
||||
## 5. Challenge engine
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- Challenge listing and detail pages exist in `app/Http/Controllers/Web/NovaCardsController.php`
|
||||
- Challenge admin management exists in `app/Http/Controllers/Settings/NovaCardAdminController.php`
|
||||
- Studio challenge submission controls exist in `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
- Challenge landing content appears on editorial surfaces via `app/Http/Controllers/Web/NovaCardsController.php`
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- No recurring challenge series system or scheduled pipeline was found.
|
||||
- No challenge archive/history navigation system was found.
|
||||
- No challenge-specific template suggestion rail beyond generic editor options was found.
|
||||
|
||||
Verdict:
|
||||
- Challenge support is solid and operational, but it does not yet match the spec's more central repeatable challenge engine.
|
||||
|
||||
## 6. AI-assisted creation
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- AI assist service exists in `app/Services/NovaCards/NovaCardAiAssistService.php`
|
||||
- The Studio AI panel exists in `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
- Suggestions include tags, mood, layouts, backgrounds, font pairing, and readability fixes in `app/Services/NovaCards/NovaCardAiAssistService.php`
|
||||
- Suggestions are creator-controlled and optional in the Studio UI
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- The service is explicitly rule-based/fallback-first, not a deeper AI composition layer.
|
||||
- No explicit story-conversion or wallpaper-conversion AI actions were found.
|
||||
- No stronger metadata/title/description assist actions were found.
|
||||
|
||||
Verdict:
|
||||
- AI assist is real and useful, but still much smaller than the spec's longer-term assistive workflow.
|
||||
|
||||
## 7. Asset and template ecosystem
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- Official asset packs and template packs are configured in `config/nova_cards.php`
|
||||
- Asset-pack and template admin surfaces exist in `app/Http/Controllers/Settings/NovaCardAdminController.php`
|
||||
- Advanced editor controls expose frame, color grade, quote-mark, and text-panel systems in `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
- Creator presets exist and are exposed in Studio through `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- No explicit template favorites system was found.
|
||||
- No access-rule-aware premium gating or premium-ready pack entitlement system was found.
|
||||
- No richer seasonal/official pack surfacing beyond config/admin management was found.
|
||||
|
||||
Verdict:
|
||||
- The pack/template ecosystem exists, but it is still closer to a solid internal/content foundation than the full ecosystem described in the spec.
|
||||
|
||||
## 8. Export and distribution
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- Export request model/table support exists in `database/migrations/2026_03_27_010000_add_nova_cards_v3_tables.php` and `app/Models/NovaCardExport.php`
|
||||
- Export request creation and polling are implemented in `app/Services/NovaCards/NovaCardExportService.php` and `app/Http/Controllers/Api/NovaCards/NovaCardExportController.php`
|
||||
- Queued export generation exists in `app/Jobs/NovaCards/GenerateNovaCardExportJob.php`
|
||||
- Preview, hires, square, story, wallpaper, and OG exports are supported in `app/Services/NovaCards/NovaCardExportService.php`
|
||||
- Export controls are exposed in Studio in `resources/js/Pages/Studio/StudioCardEditor.jsx`
|
||||
- Watermark preference is normalized into project data in `app/Services/NovaCards/NovaCardProjectNormalizer.php`
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- No actual watermark policy UI or export-policy workflow was found; only a normalized preference field exists.
|
||||
- No export history management surface was found in Studio.
|
||||
- No richer saved export-preset system per user or per preset was found.
|
||||
|
||||
Verdict:
|
||||
- Export infrastructure is strong, but the broader distribution/product layer around it remains incomplete.
|
||||
|
||||
## 9. Trust, moderation, and rights
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- Card moderation fields and staff moderation surfaces exist in `app/Http/Controllers/Settings/NovaCardAdminController.php` and `resources/js/Pages/Collection/NovaCardsAdminIndex.jsx`
|
||||
- Reporting queue integration exists in the Nova Cards admin surface
|
||||
- Rights flags such as `allow_remix`, `allow_background_reuse`, and `allow_export` are exposed in Studio and policy/service code
|
||||
- Public reporting hooks and moderation-aware visibility exist across card/challenge/comment flows
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- No duplicate or near-duplicate Nova Cards detection service was found.
|
||||
- No remix-loop abuse controls were found.
|
||||
- No branch-freeze or targeted remix-branch intervention tooling was found.
|
||||
- No stronger low-quality-content throttling specific to Nova Cards was found beyond general rate limits and moderation baselines.
|
||||
|
||||
Verdict:
|
||||
- Moderation and rights are credible at the foundation level, but the stronger anti-abuse tooling described in v3 is still missing.
|
||||
|
||||
## 10. Migration compatibility and ops tooling
|
||||
|
||||
Status:
|
||||
- Partial
|
||||
|
||||
Implemented evidence:
|
||||
- Legacy normalization is handled in `app/Services/NovaCards/NovaCardProjectNormalizer.php`
|
||||
- v3 schema additions are present in `database/migrations/2026_03_27_010000_add_nova_cards_v3_tables.php`
|
||||
- Compatibility behavior is covered by the Nova Cards v3 feature suite
|
||||
|
||||
Still missing or clearly incomplete:
|
||||
- No explicit schema backfill command was found.
|
||||
- No targeted rerender command/job for migrated cards was found.
|
||||
- No admin-safe migration utility surface with dry-run/reporting was found.
|
||||
|
||||
Verdict:
|
||||
- Compatibility is implemented. Operational migration tooling is not.
|
||||
|
||||
## Overall conclusion
|
||||
|
||||
Current repo state versus the v3 spec:
|
||||
- The v3 foundation is implemented.
|
||||
- Several public and creator-facing roadmap items that used to be missing are now present, including featured works, featured collections, and featured creators.
|
||||
- The biggest remaining gaps are no longer basic public discovery or staff creator featuring.
|
||||
- The largest remaining gaps are advanced studio depth, deeper creator portfolio identity, stronger anti-abuse tooling, and migration/rerender operations.
|
||||
|
||||
If prioritizing by product leverage from the current codebase, the clean next targets are:
|
||||
1. Creator portfolio depth and creator challenge/remix history.
|
||||
2. Anti-abuse moderation hardening for duplicate and remix-loop control.
|
||||
3. Migration/backfill/rerender tooling.
|
||||
@@ -0,0 +1,245 @@
|
||||
# Nova Cards v3 Priority Roadmap
|
||||
|
||||
Roadmap date: 2026-03-28
|
||||
|
||||
Companion document:
|
||||
- `docs/nova-cards-v3-audit-checklist.md`
|
||||
|
||||
Purpose:
|
||||
- Convert the v3 audit into an execution order
|
||||
- Focus first on features that compound value on top of the existing Nova Cards v3 foundation
|
||||
- Avoid spending time on low-leverage polish before the public creator/discovery loop is stronger
|
||||
|
||||
Assumptions:
|
||||
- Current foundation is stable: schema versioning, Studio, presets, AI assist, exports, rising, related cards, public browsing, remix lineage, collections, challenges, and moderation/reporting are already working
|
||||
- Prioritization favors retention, discovery, and creator identity ahead of deeper niche editor polish
|
||||
|
||||
Status legend:
|
||||
- `P0` Next build target
|
||||
- `P1` High-value follow-up
|
||||
- `P2` Important but can wait
|
||||
- `P3` Long-tail / later platform work
|
||||
|
||||
## Prioritization principles
|
||||
|
||||
1. Build on shipped behavior, not on spec completeness alone.
|
||||
2. Prefer features that increase creation, publishing, and rediscovery loops.
|
||||
3. Prefer public-facing discoverability and creator identity before deep editor complexity.
|
||||
4. Keep schema-safe, testable increments.
|
||||
5. Defer expensive operational tooling until the product surface justifies it.
|
||||
|
||||
## P0: Public Creator Identity And Discovery Expansion
|
||||
|
||||
Why first:
|
||||
- The editor already has enough capability to create cards.
|
||||
- The biggest missing product loop is stronger public identity and richer discovery after publishing.
|
||||
- These features make every existing card and creator more valuable without requiring a full editor rewrite.
|
||||
|
||||
Scope:
|
||||
- Add richer creator card pages beyond a flat list
|
||||
- Add public metadata surfaces for style family, top tags, and top categories
|
||||
- Add creator “featured works” support
|
||||
- Add dedicated public feeds for style family and mood
|
||||
- Add a “best remixes” or expanded remix-discovery surface
|
||||
|
||||
Suggested deliverables:
|
||||
- Creator profile enhancements on the Nova Cards creator page:
|
||||
- featured cards rail
|
||||
- creator stats summary
|
||||
- top styles / tags / categories summary
|
||||
- cards grouped or filterable by style family
|
||||
- New public routes/pages:
|
||||
- `/cards/styles/{style}`
|
||||
- `/cards/moods/{mood}` or a tag-backed mood feed if mood remains AI/tag derived
|
||||
- `/cards/remix-highlights` or equivalent best-remixes feed
|
||||
- Presenter/controller additions for creator-facing payloads and public discovery filters
|
||||
- Feature tests for all new public routes and canonical behavior
|
||||
|
||||
Why this is P0 instead of more Studio work:
|
||||
- It improves the creator growth loop immediately
|
||||
- It helps discovery quality with existing content
|
||||
- It avoids prematurely overbuilding advanced editor controls before public demand is better supported
|
||||
|
||||
## P1: Advanced Studio Usability Pass
|
||||
|
||||
Why second:
|
||||
- The Studio is already functional and valuable
|
||||
- The highest-value missing editor work is usability, not unrestricted complexity
|
||||
- This pass should improve serious creator workflows without turning Nova Cards into a full design app
|
||||
|
||||
Scope:
|
||||
- Reorder text blocks
|
||||
- Compare current draft against a selected snapshot at a useful summary level
|
||||
- Add clearer block visibility controls and editing affordances
|
||||
- Introduce a true quick mode versus advanced mode split in the Studio UI
|
||||
|
||||
Suggested deliverables:
|
||||
- Text block reorder controls using simple move up/down actions first
|
||||
- Version compare summary UI:
|
||||
- changed title/quote/source
|
||||
- changed layout/background/style sections
|
||||
- changed tags/publish settings
|
||||
- Quick mode:
|
||||
- format
|
||||
- background/template
|
||||
- main text
|
||||
- preset/style
|
||||
- preview/publish
|
||||
- Advanced mode:
|
||||
- current full control surface
|
||||
- Tests for restore and compare API behavior where needed, plus front-end interaction coverage if available in the repo
|
||||
|
||||
Explicitly not in this phase:
|
||||
- freeform drag canvas
|
||||
- arbitrary layer transforms
|
||||
- unbounded design-tool behavior
|
||||
|
||||
## P1: Editorial And Themed Discovery Surfaces
|
||||
|
||||
Why this is also P1:
|
||||
- Once creator identity is stronger, editorial discovery becomes more valuable
|
||||
- The spec calls for featured/editorial/seasonal surfaces, but these are not necessary before the base creator/discovery loop is improved
|
||||
|
||||
Scope:
|
||||
- Add editorial landing support for Nova Cards
|
||||
- Add seasonal or themed Nova Card hubs
|
||||
- Add admin-facing hooks for featuring creators/cards/collections/challenges more intentionally
|
||||
|
||||
Suggested deliverables:
|
||||
- `cards/editorial` landing page
|
||||
- `cards/seasonal` landing page
|
||||
- Staff-configurable featured creator/card slots or score-driven sections
|
||||
- Reusable presenter format for editorial rails:
|
||||
- featured creators
|
||||
- challenge highlights
|
||||
- collection picks
|
||||
- best remixes
|
||||
|
||||
## P2: AI Assist And Export Expansion
|
||||
|
||||
Why later:
|
||||
- The current AI assist and export layers already provide useful value
|
||||
- They do not block core creation or public discovery
|
||||
- Expanding them before creator/discovery surfaces would be lower leverage
|
||||
|
||||
Scope:
|
||||
- Add higher-level assist actions
|
||||
- Add export presets and clearer output workflows
|
||||
|
||||
Suggested deliverables:
|
||||
- AI assist actions:
|
||||
- suggest story format conversion
|
||||
- suggest wallpaper crop/layout
|
||||
- suggest palette/style family
|
||||
- suggest stronger title/description metadata
|
||||
- Export enhancements:
|
||||
- save default export preference per creator preset or per user
|
||||
- explicit watermark policy options if needed by product policy
|
||||
- better export status/history presentation in Studio
|
||||
|
||||
## P2: Moderation And Abuse Hardening
|
||||
|
||||
Why later:
|
||||
- The current moderation/reporting baseline is already present
|
||||
- This work matters, but it becomes more urgent as creator/discovery scale increases
|
||||
|
||||
Scope:
|
||||
- stronger duplicate/near-duplicate detection hooks
|
||||
- remix abuse controls
|
||||
- moderation intervention for problematic remix branches
|
||||
|
||||
Suggested deliverables:
|
||||
- duplicate-card heuristic service hook on publish
|
||||
- remix-rate guardrails for suspicious loops
|
||||
- moderation action to freeze remixing on flagged cards or branches
|
||||
- additional reporting context on lineage-heavy cards
|
||||
|
||||
## P3: Migration Ops And Long-Term Platform Tooling
|
||||
|
||||
Why last:
|
||||
- These are important platform investments, but they are less visible to users right now
|
||||
- The existing normalization path keeps older content functioning
|
||||
|
||||
Scope:
|
||||
- schema backfill commands/jobs
|
||||
- rerender tools for migrated cards
|
||||
- operational utilities for long-term format changes
|
||||
|
||||
Suggested deliverables:
|
||||
- artisan command to backfill `schema_version`
|
||||
- job/command to rerender a targeted subset of cards
|
||||
- admin-safe batch tooling with dry-run mode
|
||||
- audit/reporting output for migration health
|
||||
|
||||
## Recommended execution order
|
||||
|
||||
1. Creator portfolio depth beyond featured works and featured collections
|
||||
2. Stronger editorial packaging on top of the existing editorial and seasonal pages
|
||||
3. AI assist expansion
|
||||
4. Moderation hardening
|
||||
5. Migration/backfill/rerender tooling
|
||||
|
||||
## Suggested implementation slices
|
||||
|
||||
### Slice A: Creator page upgrade
|
||||
- Add featured cards rail
|
||||
- Add creator stats summary
|
||||
- Add style/tag/category summary
|
||||
- Add tests for creator page payload and rendering
|
||||
|
||||
Status:
|
||||
- Partially implemented
|
||||
|
||||
### Slice B: Discovery page upgrade
|
||||
- Add style-family route and controller action
|
||||
- Add mood route and controller action or tag-backed mood filter
|
||||
- Add best-remixes feed
|
||||
- Add tests for public feeds and filters
|
||||
|
||||
Status:
|
||||
- Materially implemented for current public discovery breadth
|
||||
|
||||
### Slice C: Studio usability
|
||||
- Add quick mode toggle
|
||||
- Add text block reorder controls
|
||||
- Add version compare summary
|
||||
- Add front-end coverage if present in the repo workflow
|
||||
|
||||
Status:
|
||||
- Implemented
|
||||
|
||||
### Slice D: Editorial/public packaging
|
||||
- Add seasonal/editorial landing controllers and views
|
||||
- Add admin config or featured slots
|
||||
- Add regression coverage for public rendering
|
||||
|
||||
Status:
|
||||
- Public editorial and seasonal landings are implemented, and staff-facing creator featuring controls now exist; the remaining gap is broader editorial packaging depth
|
||||
|
||||
## Do not prioritize yet
|
||||
|
||||
- Freeform drag canvas architecture
|
||||
- Arbitrary layer transforms and rotation system
|
||||
- Premium monetization logic
|
||||
- Deep animation tooling
|
||||
- Heavy AI dependence that weakens deterministic/testable behavior
|
||||
|
||||
## Recommended next implementation target
|
||||
|
||||
If work starts immediately, the best next build target is:
|
||||
|
||||
`P1: Editorial controls and creator curation depth`
|
||||
|
||||
Reason:
|
||||
- The public discovery breadth and Studio usability gaps have already been addressed enough for the next increment.
|
||||
- The highest remaining leverage is better curation: featured works on creator pages, featured creators/cards/collections/challenges, and stronger staff control over public surfacing.
|
||||
- This closes the gap between existing public landing pages and the fuller editorial ecosystem described by the spec.
|
||||
|
||||
Update:
|
||||
- `P0 Slice A: Creator page upgrade` is now partially implemented through creator summary stats, top category/tag metadata, and creator highlight cards on the public creator page.
|
||||
- `P0 Slice B` is now partially implemented through public style-family and palette-family discovery pages.
|
||||
- Public best-remix discovery is also now implemented.
|
||||
- Mood discovery is now implemented through durable config-backed tag mappings.
|
||||
- Editorial and seasonal landing pages are now also implemented.
|
||||
- `P1 Slice C: Studio usability` is now implemented through quick versus advanced mode, text block reordering, version compare summaries, and the supporting payload/test coverage.
|
||||
- The next strongest gap is no longer public discovery breadth or the Studio usability pass; it is stronger editorial controls and creator-facing curation depth.
|
||||
48
.deploy/artwork-evolution-release/docs/realtime-messaging.md
Normal file
48
.deploy/artwork-evolution-release/docs/realtime-messaging.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Realtime Messaging
|
||||
|
||||
Skinbase Nova messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, Redis-backed queues, and Laravel Horizon for queue visibility.
|
||||
|
||||
## v2 capabilities
|
||||
|
||||
- Presence is exposed through a global `presence-messaging` channel for inbox-level online state.
|
||||
- Conversation presence still uses the per-thread presence channel so the header can show who is actively viewing the room.
|
||||
- Typing indicators remain ephemeral and Redis-backed.
|
||||
- Read markers are stored on conversation participants and expanded into `message_reads` for durable receipts.
|
||||
- The conversation list response now includes `summary.unread_total` for global badge consumers.
|
||||
- Reconnect recovery uses `GET /api/messages/{conversation_id}/delta?after_message_id=...`.
|
||||
- Delta recovery also returns the latest conversation summary and aggregate unread total so the inbox sidebar can heal after a missed `conversation.updated` broadcast.
|
||||
- Presence heartbeats use `POST /api/messages/presence/heartbeat` and are intended only to support offline fallback notification logic plus server-side presence awareness.
|
||||
- Message sends are idempotent per sender and conversation when the client retries with the same `client_temp_id`.
|
||||
- The database also enforces sender-scoped `client_temp_id` uniqueness so retry races cannot persist duplicate rows.
|
||||
|
||||
## Local setup
|
||||
|
||||
1. Set the Reverb, Redis, messaging, and Horizon values in `.env`.
|
||||
2. Run `php artisan migrate`.
|
||||
3. Run `npm install` if dependencies are not installed.
|
||||
4. Start the websocket server with `php artisan reverb:start --host=0.0.0.0 --port=8080`.
|
||||
5. Start queue workers with `php artisan queue:work redis --queue=broadcasts,notifications,default --tries=1`.
|
||||
6. Start the frontend with `npm run dev` or build assets with `npm run build`.
|
||||
|
||||
## Horizon
|
||||
|
||||
- Horizon is installed for production queue monitoring and uses dedicated supervisors for `broadcasts` and `notifications` alongside the default queue.
|
||||
- The scheduler now runs `php artisan horizon:snapshot` every five minutes so the dashboard records queue metrics.
|
||||
- On Windows development machines, Horizon itself cannot run because PHP lacks `ext-pcntl` and `ext-posix`; that limitation does not affect Linux production deployments.
|
||||
- Use `php artisan horizon` on Linux-based environments and keep the dashboard behind the `viewHorizon` gate.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Use `BROADCAST_CONNECTION=reverb` and `QUEUE_CONNECTION=redis`.
|
||||
- Keep `MESSAGING_REALTIME=true` only when Reverb is configured and reachable from the browser.
|
||||
- Terminate TLS in Nginx and proxy websocket traffic to the Reverb process.
|
||||
- Run `php artisan reverb:start` and `php artisan horizon` under Supervisor or systemd.
|
||||
- The chat UI falls back to HTTP polling only when realtime is disabled in config.
|
||||
- Database notification fallback now only runs for recipients who are not marked online in messaging presence.
|
||||
|
||||
## Reconnect model
|
||||
|
||||
- The conversation view loads once via HTTP.
|
||||
- Live message, read, typing, and conversation summary updates arrive over websocket channels.
|
||||
- When the socket reconnects, the client requests deltas from the explicit `delta` endpoint and merges them idempotently by message id, UUID, and client temp id.
|
||||
- HTTP send retries also reuse the original stored message when `client_temp_id` matches, preventing duplicate broadcasts during reconnect races.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Recommendation AI Production Readiness
|
||||
|
||||
This runbook covers the production prerequisites for the Skinbase recommendation AI stack:
|
||||
|
||||
- vision analysis via `VISION_GATEWAY_URL` or CLIP/YOLO endpoints
|
||||
- embedding generation
|
||||
- vector upsert/search via the vector gateway
|
||||
- hybrid discovery feed v2/v3
|
||||
- upload-triggered AI processing
|
||||
|
||||
## 1. Required runtime services
|
||||
|
||||
You need these services available before enabling the stack:
|
||||
|
||||
- Laravel queue workers
|
||||
- a configured public files/CDN base so artwork derivatives resolve to public URLs
|
||||
- a vision gateway for `/analyze/all`, or CLIP and YOLO services individually
|
||||
- a vector gateway exposing `/vectors/upsert` and `/vectors/search`
|
||||
- a queue backend suitable for async jobs, preferably Redis in production
|
||||
|
||||
## 2. Required environment variables
|
||||
|
||||
### Core queue and upload
|
||||
|
||||
```dotenv
|
||||
QUEUE_CONNECTION=redis
|
||||
UPLOAD_QUEUE_DERIVATIVES=true
|
||||
VISION_QUEUE=vision
|
||||
RECOMMENDATIONS_QUEUE=recommendations
|
||||
DISCOVERY_QUEUE=discovery
|
||||
```
|
||||
|
||||
If you do not want dedicated queues, leave the queue names unset or set them to `default`.
|
||||
|
||||
### Vision analysis
|
||||
|
||||
```dotenv
|
||||
VISION_ENABLED=true
|
||||
VISION_IMAGE_VARIANT=md
|
||||
|
||||
VISION_GATEWAY_URL=https://vision.example.com
|
||||
VISION_GATEWAY_TIMEOUT=10
|
||||
VISION_GATEWAY_CONNECT_TIMEOUT=3
|
||||
|
||||
CLIP_BASE_URL=https://clip.example.com
|
||||
CLIP_ANALYZE_ENDPOINT=/analyze
|
||||
CLIP_EMBED_ENDPOINT=/embed
|
||||
|
||||
YOLO_ENABLED=true
|
||||
YOLO_BASE_URL=https://yolo.example.com
|
||||
YOLO_ANALYZE_ENDPOINT=/analyze
|
||||
YOLO_PHOTOGRAPHY_ONLY=true
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `VISION_GATEWAY_URL` is the preferred unified path because the app can ingest CLIP tags, BLIP caption, and YOLO objects from `/analyze/all`
|
||||
- CLIP embedding generation still uses the CLIP embed endpoint configured in `config/recommendations.php`
|
||||
|
||||
### Vector search and indexing
|
||||
|
||||
```dotenv
|
||||
VISION_VECTOR_GATEWAY_ENABLED=true
|
||||
VISION_VECTOR_GATEWAY_URL=https://vector.example.com
|
||||
VISION_VECTOR_GATEWAY_API_KEY=replace-me
|
||||
VISION_VECTOR_GATEWAY_COLLECTION=images
|
||||
VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT=/vectors/upsert
|
||||
VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT=/vectors/search
|
||||
VISION_VECTOR_GATEWAY_DELETE_ENDPOINT=/vectors/delete
|
||||
```
|
||||
|
||||
### Discovery and rollout
|
||||
|
||||
```dotenv
|
||||
DISCOVERY_V2_ENABLED=true
|
||||
DISCOVERY_V2_ALGO_VERSION=clip-cosine-v2-adaptive
|
||||
DISCOVERY_V2_ROLLOUT_PERCENTAGE=100
|
||||
|
||||
DISCOVERY_V3_ENABLED=true
|
||||
DISCOVERY_V3_CACHE_TTL_MINUTES=5
|
||||
DISCOVERY_V3_VECTOR_SIMILARITY_WEIGHT=0.8
|
||||
DISCOVERY_V3_VECTOR_BASE_SCORE=0.75
|
||||
DISCOVERY_V3_MAX_SEED_ARTWORKS=3
|
||||
DISCOVERY_V3_VECTOR_CANDIDATE_POOL=60
|
||||
```
|
||||
|
||||
### Embeddings
|
||||
|
||||
```dotenv
|
||||
RECOMMENDATIONS_EMBEDDING_ENABLED=true
|
||||
RECOMMENDATIONS_EMBEDDING_MODEL=clip
|
||||
RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1
|
||||
RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1
|
||||
RECOMMENDATIONS_MIN_DIM=64
|
||||
RECOMMENDATIONS_MAX_DIM=4096
|
||||
```
|
||||
|
||||
## 3. Queue worker coverage
|
||||
|
||||
The AI stack dispatches jobs onto these queue families:
|
||||
|
||||
- `vision` via `AutoTagArtworkJob`
|
||||
- `recommendations` via embedding generation, vector backfill, and similarity jobs
|
||||
- `discovery` via feed cache regeneration
|
||||
- `default` if dedicated queue env vars are not set
|
||||
|
||||
The example worker configs in `deploy/` now listen on:
|
||||
|
||||
```text
|
||||
forum-security,forum-moderation,vision,recommendations,discovery,mail,default
|
||||
```
|
||||
|
||||
If you run separate workers per queue, ensure all configured queue names are consumed somewhere.
|
||||
|
||||
## 4. Upload pipeline behavior
|
||||
|
||||
Upload finish dispatches these async jobs after derivatives exist:
|
||||
|
||||
- `AutoTagArtworkJob`
|
||||
- `GenerateArtworkEmbeddingJob`
|
||||
|
||||
If `UPLOAD_QUEUE_DERIVATIVES=true`, derivative generation is also queued before those jobs can run. That means a broken worker setup can block the entire AI post-processing chain even if uploads themselves succeed.
|
||||
|
||||
## 5. Database and schema expectations
|
||||
|
||||
Confirm the database includes the current AI/discovery schema:
|
||||
|
||||
- artwork AI metadata columns on `artworks`
|
||||
- `last_vector_indexed_at` on `artworks`
|
||||
- artwork embeddings table
|
||||
- discovery cache and event tables used by feed generation
|
||||
|
||||
Recommended checks:
|
||||
|
||||
```bash
|
||||
php artisan migrate --force
|
||||
php artisan schema:audit-migrations
|
||||
```
|
||||
|
||||
## 6. Deployment checklist
|
||||
|
||||
Run these after deploy:
|
||||
|
||||
```bash
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan queue:restart
|
||||
php artisan optimize
|
||||
```
|
||||
|
||||
If you changed frontend assets, also ensure the Vite manifest exists before smoke-testing browser flows.
|
||||
|
||||
## 7. Smoke tests
|
||||
|
||||
Recommended application-level checks:
|
||||
|
||||
```bash
|
||||
php artisan artworks:vectors-index --limit=1
|
||||
php artisan artworks:vectors-search {artwork_id} --limit=5
|
||||
vendor/bin/pest tests/Feature/Vision/GenerateArtworkEmbeddingJobTest.php
|
||||
vendor/bin/pest tests/Feature/Vision/AiArtworkSearchApiTest.php
|
||||
vendor/bin/pest tests/Feature/Discovery/FeedEndpointV2Test.php
|
||||
```
|
||||
|
||||
Recommended HTTP checks:
|
||||
|
||||
- `GET /api/art/{id}/similar-ai`
|
||||
- `POST /api/search/image`
|
||||
- `GET /api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=12`
|
||||
|
||||
## 8. Failure modes to watch
|
||||
|
||||
### Uploads succeed but AI fields stay empty
|
||||
|
||||
Likely causes:
|
||||
|
||||
- workers are not running
|
||||
- workers do not consume `vision` or `recommendations`
|
||||
- `VISION_ENABLED=false`
|
||||
- derivative URLs are not publicly reachable by the vision services
|
||||
|
||||
### Similar AI endpoints return 503
|
||||
|
||||
Likely causes:
|
||||
|
||||
- `VISION_VECTOR_GATEWAY_ENABLED=false`
|
||||
- missing `VISION_VECTOR_GATEWAY_URL`
|
||||
- missing `VISION_VECTOR_GATEWAY_API_KEY`
|
||||
|
||||
### Feed works but has no vector influence
|
||||
|
||||
Likely causes:
|
||||
|
||||
- `DISCOVERY_V3_ENABLED=false`
|
||||
- vector gateway search unavailable
|
||||
- no recent user seed artworks
|
||||
- artworks have local embeddings but were never upserted
|
||||
|
||||
### Vector repair/backfill stalls
|
||||
|
||||
Likely causes:
|
||||
|
||||
- `RECOMMENDATIONS_QUEUE` is set but workers do not listen on it
|
||||
- queue backend is unhealthy
|
||||
|
||||
## 9. Operational recommendation
|
||||
|
||||
For first rollout, keep the queue names explicit but simple:
|
||||
|
||||
```dotenv
|
||||
VISION_QUEUE=default
|
||||
RECOMMENDATIONS_QUEUE=default
|
||||
DISCOVERY_QUEUE=default
|
||||
```
|
||||
|
||||
Once the stack is stable, split them into dedicated workers only if queue volume justifies it.
|
||||
209
.deploy/artwork-evolution-release/docs/registration-antispam.md
Normal file
209
.deploy/artwork-evolution-release/docs/registration-antispam.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Registration Anti-Spam + Email Quota Protection
|
||||
|
||||
This document describes how the Skinbase email-first registration hardening works.
|
||||
|
||||
## Scope
|
||||
|
||||
Applies to the flow:
|
||||
|
||||
- `GET /register`
|
||||
- `POST /register`
|
||||
- `GET /register/notice`
|
||||
- `POST /register/resend-verification`
|
||||
- `GET /verify/{token}`
|
||||
- `GET/POST /setup/password`
|
||||
- `GET/POST /setup/username`
|
||||
|
||||
Primary implementation:
|
||||
|
||||
- `app/Http/Controllers/Auth/RegisteredUserController.php`
|
||||
- `app/Http/Controllers/Auth/RegistrationVerificationController.php`
|
||||
|
||||
## Security Controls
|
||||
|
||||
### 1) IP Rate Limiting
|
||||
|
||||
Defined in `app/Providers/AppServiceProvider.php`:
|
||||
|
||||
- `register-ip`: per-minute IP limit
|
||||
- `register-ip-daily`: per-day IP limit
|
||||
- `register` (legacy resend route): per-minute IP + per-email key
|
||||
|
||||
Applied on `POST /register` in `routes/auth.php`:
|
||||
|
||||
- `throttle:register-ip`
|
||||
- `throttle:register-ip-daily`
|
||||
|
||||
### 2) Per-Email Cooldown
|
||||
|
||||
Cooldown is enforced by user fields:
|
||||
|
||||
- `users.last_verification_sent_at`
|
||||
- `users.verification_send_count_24h`
|
||||
- `users.verification_send_window_started_at`
|
||||
|
||||
On repeated requests within cooldown:
|
||||
|
||||
- No additional verification email is queued
|
||||
- Generic success message is returned
|
||||
|
||||
### 3) Progressive CAPTCHA
|
||||
|
||||
Service:
|
||||
|
||||
- `app/Services/Security/CaptchaVerifier.php`
|
||||
- `app/Services/Security/TurnstileVerifier.php` (legacy compatibility wrapper)
|
||||
|
||||
Controller logic (`RegisteredUserController::shouldRequireCaptcha`):
|
||||
|
||||
- Requires CAPTCHA for suspicious IP activity (attempt threshold)
|
||||
- Also requires CAPTCHA when registration rate-limit state is detected
|
||||
- Active provider is selected through `forum_bot_protection.captcha.provider`
|
||||
|
||||
UI behavior (`resources/views/auth/register.blade.php`):
|
||||
|
||||
- Provider-specific widget is only rendered when required
|
||||
- Turnstile, reCAPTCHA, and hCaptcha are supported
|
||||
|
||||
### 4) Disposable Domain Block
|
||||
|
||||
Service:
|
||||
|
||||
- `app/Services/Auth/DisposableEmailService.php`
|
||||
|
||||
Config source:
|
||||
|
||||
- `config/disposable_email_domains.php`
|
||||
|
||||
Behavior:
|
||||
|
||||
- Blocks known disposable domains (supports wildcard matching)
|
||||
- Returns friendly validation error
|
||||
|
||||
### 5) Queue + Throttle + Quota Circuit Breaker
|
||||
|
||||
Queue job:
|
||||
|
||||
- `app/Jobs/SendVerificationEmailJob.php`
|
||||
|
||||
Behavior:
|
||||
|
||||
- Registration controller dispatches `SendVerificationEmailJob`
|
||||
- Job applies global send throttling via `RateLimiter`
|
||||
- Job checks monthly quota via `RegistrationEmailQuotaService`
|
||||
- If quota exceeded: send is blocked (fail closed), event marked blocked
|
||||
|
||||
Quota service/model/table:
|
||||
|
||||
- `app/Services/Auth/RegistrationEmailQuotaService.php`
|
||||
- `app/Models/SystemEmailQuota.php`
|
||||
- `system_email_quota`
|
||||
|
||||
Send event audit:
|
||||
|
||||
- `app/Models/EmailSendEvent.php`
|
||||
- `email_send_events`
|
||||
|
||||
### 6) Generic Responses (Anti-Enumeration)
|
||||
|
||||
The registration entry point uses a standard success message:
|
||||
|
||||
- `If that email is valid, we sent a verification link.`
|
||||
|
||||
This message is returned for:
|
||||
|
||||
- Unknown emails
|
||||
- Existing verified emails
|
||||
- Cooldown cases
|
||||
- Quota-blocked paths
|
||||
|
||||
### 7) Verification Token Hardening
|
||||
|
||||
Service:
|
||||
|
||||
- `app/Services/Auth/RegistrationVerificationTokenService.php`
|
||||
|
||||
Protections:
|
||||
|
||||
- Token generated with high entropy (`Str::random(64)`)
|
||||
- Stored hashed (`sha256`) in `user_verification_tokens`
|
||||
- Expires using configured TTL
|
||||
- Validation uses hash lookup + constant-time compare (`hash_equals`)
|
||||
- Token deleted after successful verification (one-time use)
|
||||
|
||||
Verification endpoint:
|
||||
|
||||
- `app/Http/Controllers/Auth/RegistrationVerificationController.php`
|
||||
|
||||
## Configuration
|
||||
|
||||
Main registration config:
|
||||
|
||||
- `config/registration.php`
|
||||
|
||||
Key settings:
|
||||
|
||||
- `ip_per_minute_limit`
|
||||
- `ip_per_day_limit`
|
||||
- `email_per_minute_limit`
|
||||
- `email_cooldown_minutes`
|
||||
- `verify_token_ttl_hours`
|
||||
- `enable_turnstile`
|
||||
- `disposable_domains_enabled`
|
||||
- `turnstile_suspicious_attempts`
|
||||
- `turnstile_attempt_window_minutes`
|
||||
- `email_global_send_per_minute`
|
||||
- `monthly_email_limit`
|
||||
- `generic_success_message`
|
||||
|
||||
Captcha provider config:
|
||||
|
||||
- `config/services.php` under `turnstile`, `recaptcha`, and `hcaptcha`
|
||||
- `config/forum_bot_protection.php` under `captcha`
|
||||
|
||||
Environment examples:
|
||||
|
||||
- `.env.example` contains all registration anti-spam keys
|
||||
|
||||
## Database Objects
|
||||
|
||||
Added for anti-spam/quota support:
|
||||
|
||||
- Migration: `2026_02_21_000001_add_registration_antispam_fields_to_users_table.php`
|
||||
- Migration: `2026_02_21_000002_create_email_send_events_table.php`
|
||||
- Migration: `2026_02_21_000003_create_system_email_quota_table.php`
|
||||
- Migration: `2026_02_20_191000_add_registration_phase1_schema.php` (creates `user_verification_tokens`)
|
||||
- Migration: `2026_02_21_000004_rename_token_to_token_hash_in_user_verification_tokens.php` (schema hardening)
|
||||
- Migration: `2026_02_21_000005_ensure_user_verification_tokens_table_exists.php` (rollout safety)
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Primary tests:
|
||||
|
||||
- `tests/Feature/Auth/RegistrationAntiSpamTest.php`
|
||||
- `tests/Feature/Auth/RegistrationNoticeResendTest.php`
|
||||
- `tests/Feature/Auth/RegistrationQuotaCircuitBreakerTest.php`
|
||||
- `tests/Feature/Auth/RegistrationTokenVerificationTest.php`
|
||||
- `tests/Feature/Auth/RegistrationFlowChecklistTest.php`
|
||||
- `tests/Feature/Auth/RegistrationVerificationMailTest.php`
|
||||
|
||||
Covered scenarios:
|
||||
|
||||
- IP rate-limit returns `429`
|
||||
- Cooldown suppresses extra sends
|
||||
- Disposable domains blocked
|
||||
- Quota exceeded blocks send and keeps generic success UX
|
||||
- CAPTCHA required on abuse/rate-limit state
|
||||
- Tokens hashed, expire, and are one-time
|
||||
- Responses avoid account enumeration
|
||||
|
||||
## Operations Notes
|
||||
|
||||
- Keep disposable domain list maintained in `config/disposable_email_domains.php`.
|
||||
- Ensure queue workers process the `mail` queue.
|
||||
- Monitor `email_send_events` for blocked/sent patterns.
|
||||
- Set `REGISTRATION_MONTHLY_EMAIL_LIMIT` based on provider quota.
|
||||
- Configure the active CAPTCHA provider keys in production:
|
||||
- Turnstile: `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`
|
||||
- reCAPTCHA: `RECAPTCHA_ENABLED`, `RECAPTCHA_SITE_KEY`, `RECAPTCHA_SECRET_KEY`
|
||||
- hCaptcha: `HCAPTCHA_ENABLED`, `HCAPTCHA_SITE_KEY`, `HCAPTCHA_SECRET_KEY`
|
||||
@@ -0,0 +1,112 @@
|
||||
# SEO Audit System v1 Summary
|
||||
|
||||
## Changed Areas
|
||||
|
||||
- Added shared SEO support under `app/Support/Seo/`
|
||||
- Added shared Blade SEO renderer in `resources/views/partials/seo/head.blade.php`
|
||||
- Added shared Inertia SEO renderer in `resources/js/components/seo/SeoHead.jsx`
|
||||
- Updated layout integration in `resources/views/layouts/nova.blade.php` and `resources/views/layouts/_legacy.blade.php`
|
||||
- Wired major public controllers and views across homepage, artworks, browse/discover, categories, tags, profiles, collections, leaderboard, blog, stories, news, Nova Cards, community/gallery pages, and static/footer pages
|
||||
- Added and updated feature tests for key public SEO surfaces
|
||||
|
||||
## SEO Architecture Introduced
|
||||
|
||||
### Core files
|
||||
|
||||
- `config/seo.php`
|
||||
- `app/Support/Seo/SeoData.php`
|
||||
- `app/Support/Seo/BreadcrumbTrail.php`
|
||||
- `app/Support/Seo/SeoDataBuilder.php`
|
||||
- `app/Support/Seo/SeoFactory.php`
|
||||
- `resources/views/partials/seo/head.blade.php`
|
||||
- `resources/js/components/seo/SeoHead.jsx`
|
||||
|
||||
### Contract
|
||||
|
||||
- Controllers and pages can pass a normalized `seo` payload explicitly
|
||||
- Blade layouts can also normalize legacy `page_*` and `meta.*` data through `SeoFactory::fromViewData()`
|
||||
- `useUnifiedSeo` is the opt-in switch for the shared renderer
|
||||
- JSON-LD, Open Graph, Twitter tags, canonical tags, robots, breadcrumbs, and optional keywords are emitted from one place
|
||||
|
||||
## Supported Page Types
|
||||
|
||||
Explicitly covered or normalized through the shared system:
|
||||
|
||||
- Homepage
|
||||
- Artwork detail pages
|
||||
- Explore / browse / discover pages
|
||||
- Category pages
|
||||
- Tag pages
|
||||
- Creator / profile pages
|
||||
- Collection listing and collection detail pages
|
||||
- Leaderboard pages
|
||||
- Blog pages
|
||||
- Stories pages
|
||||
- News pages
|
||||
- Public Nova Cards pages
|
||||
- FAQ, rules, privacy, terms, about/help/contact/legal, staff, RSS feeds, and other public static/footer pages
|
||||
- Community / gallery-style public pages such as latest uploads, featured artworks, member photos, downloads today, comments latest, and monthly commentators
|
||||
|
||||
## Title / Description / Canonical / OG Strategy
|
||||
|
||||
- Title and description generation is page-type-aware where the app has a dedicated resolver
|
||||
- Legacy pages still normalize cleanly through fallback mapping of `page_title`, `page_meta_description`, `page_canonical`, `page_robots`, and `meta.*`
|
||||
- Canonicals are generated with Laravel route and URL helpers rather than hardcoded hosts
|
||||
- Open Graph and Twitter tags are standardized through the shared renderer
|
||||
- Fallback OG images resolve from page data first, then from configured defaults
|
||||
- Search and low-value pages can be marked noindex through the same DTO/builder contract
|
||||
|
||||
## Structured Data Added
|
||||
|
||||
JSON-LD is emitted centrally and currently supports the main public shapes used by the site:
|
||||
|
||||
- `WebSite`
|
||||
- `BreadcrumbList`
|
||||
- `CollectionPage`
|
||||
- `ProfilePage`
|
||||
- `FAQPage`
|
||||
- `ImageObject`
|
||||
- `CreativeWork`
|
||||
- `Article`
|
||||
|
||||
Schema is added only where it matches visible page content.
|
||||
|
||||
## Indexing / Robots Decisions
|
||||
|
||||
- Public canonical pages default to `index,follow`
|
||||
- Search and similar low-value surfaces can be marked noindex through the shared builder
|
||||
- Private/internal pages remain outside the shared public SEO rollout
|
||||
- The system is structured to align with future sitemap generation through canonical/indexable page types
|
||||
|
||||
## Breadcrumb / Internal Linking Fixes
|
||||
|
||||
- Added `BreadcrumbTrail` normalization
|
||||
- Removed duplicate `Home > Home` style breadcrumb issues
|
||||
- Ensured breadcrumb JSON-LD is emitted once through the shared renderer when breadcrumbs exist
|
||||
|
||||
## Tests Added / Updated
|
||||
|
||||
Key coverage includes:
|
||||
|
||||
- `tests/Feature/HomePageTest.php`
|
||||
- `tests/Feature/FooterPagesTest.php`
|
||||
- `tests/Feature/Collections/CollectionsV5FeatureTest.php`
|
||||
- `tests/Feature/LeaderboardPageTest.php`
|
||||
- `tests/Feature/RoutingUnificationTest.php`
|
||||
- `tests/Feature/Stories/CreatorStoryWorkflowTest.php`
|
||||
- `tests/Feature/NovaCards/NovaCardPublicPagesTest.php`
|
||||
- `tests/Feature/ArtworkJsonLdTest.php`
|
||||
- `tests/Feature/TagPageTest.php`
|
||||
- `tests/Feature/BrowseApiTest.php`
|
||||
|
||||
These tests cover canonical tags, JSON-LD, OG output, no-duplicate-head regressions, and core public page availability.
|
||||
|
||||
## Follow-up Recommendations
|
||||
|
||||
### SEO v2
|
||||
|
||||
- Add a formal sitemap generator built directly from the canonical/indexable page-type contract
|
||||
- Expand explicit page-type resolver methods for the remaining lower-value legacy pages that still rely on fallback normalization
|
||||
- Add broader page-family assertions for static pages, news/tag/category pages, and community gallery pages
|
||||
- Add snapshot-style tests for the shared head output if head regressions become frequent
|
||||
- Consider central reporting for pages with fallback-only metadata so future migrations are easier to track
|
||||
210
.deploy/artwork-evolution-release/docs/tags-system.md
Normal file
210
.deploy/artwork-evolution-release/docs/tags-system.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Skinbase Tag System
|
||||
|
||||
Architecture reference for the Skinbase unified tag system.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
TagInput (React)
|
||||
├─ GET /api/tags/search?q= → TagController@search
|
||||
└─ GET /api/tags/popular → TagController@popular
|
||||
|
||||
ArtworkTagController
|
||||
├─ GET /api/artworks/{id}/tags
|
||||
├─ POST /api/artworks/{id}/tags → TagService::attachUserTags()
|
||||
├─ PUT /api/artworks/{id}/tags → TagService::syncTags()
|
||||
└─ DELETE /api/artworks/{id}/tags/{tag} → TagService::detachTags()
|
||||
|
||||
TagService → TagNormalizer → Tag (model) → artwork_tag (pivot)
|
||||
ArtworkObserver / TagService → IndexArtworkJob → Meilisearch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `tags`
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint PK | |
|
||||
| name | varchar(64) | unique |
|
||||
| slug | varchar(64) | unique, normalized |
|
||||
| usage_count | bigint | maintained by TagService |
|
||||
| is_active | boolean | false = hidden from search |
|
||||
| created_at / updated_at | timestamps | |
|
||||
|
||||
### `artwork_tag` (pivot)
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| artwork_id | bigint FK | |
|
||||
| tag_id | bigint FK | |
|
||||
| source | enum(user,ai,system) | |
|
||||
| confidence | float NULL | AI only |
|
||||
| created_at | timestamp | |
|
||||
|
||||
PK: `(artwork_id, tag_id)` — one row per pair, user source takes precedence over ai.
|
||||
|
||||
### `tag_synonyms`
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint PK | |
|
||||
| tag_id | bigint FK | cascade delete |
|
||||
| synonym | varchar(64) | |
|
||||
|
||||
Unique: `(tag_id, synonym)`.
|
||||
|
||||
---
|
||||
|
||||
## Services
|
||||
|
||||
### `TagNormalizer`
|
||||
|
||||
`App\Services\TagNormalizer`
|
||||
|
||||
```php
|
||||
$n->normalize(' Café Night!! '); // → 'cafe-night'
|
||||
$n->normalize('🚀 Rocket'); // → 'rocket'
|
||||
```
|
||||
|
||||
Rules applied in order:
|
||||
1. Trim + lowercase (UTF-8)
|
||||
2. Unicode → ASCII transliteration (Transliterator / iconv)
|
||||
3. Strip everything except `[a-z0-9 -]`
|
||||
4. Collapse whitespace → hyphens
|
||||
5. Strip leading/trailing hyphens
|
||||
6. Clamp to `config('tags.max_length', 32)` characters
|
||||
|
||||
### `TagService`
|
||||
|
||||
`App\Services\TagService`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `attachUserTags(Artwork, string[])` | Normalize → findOrCreate → attach with `source=user`. Skips duplicates. Max 15. |
|
||||
| `attachAiTags(Artwork, array{tag,confidence}[])` | Normalize → findOrCreate → syncWithoutDetaching `source=ai`. Existing user pivot is never overwritten. |
|
||||
| `detachTags(Artwork, string[])` | Detach by slug, decrement usage_count. |
|
||||
| `syncTags(Artwork, string[])` | Replace full user-tag set. New tags increment, removed tags decrement. |
|
||||
| `updateUsageCount(Tag, int)` | Clamp-safe increment/decrement. |
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public (no auth)
|
||||
|
||||
```
|
||||
GET /api/tags/search?q={query}&limit={n}
|
||||
GET /api/tags/popular?limit={n}
|
||||
```
|
||||
|
||||
Response shape:
|
||||
```json
|
||||
{ "data": [{ "id": 1, "name": "city", "slug": "city", "usage_count": 412 }] }
|
||||
```
|
||||
|
||||
### Authenticated (artwork owner or admin)
|
||||
|
||||
```
|
||||
GET /api/artworks/{id}/tags
|
||||
POST /api/artworks/{id}/tags body: { "tags": ["city", "night"] }
|
||||
PUT /api/artworks/{id}/tags body: { "tags": ["city", "night", "rain"] }
|
||||
DELETE /api/artworks/{id}/tags/{tag}
|
||||
```
|
||||
|
||||
All tag mutations dispatch `IndexArtworkJob` to keep Meilisearch in sync.
|
||||
|
||||
---
|
||||
|
||||
## Meilisearch Integration
|
||||
|
||||
Index name: `skinbase_prod_artworks` (prefix from `MEILI_PREFIX` env var).
|
||||
|
||||
Tags are stored in the `tags` field as an array of slugs:
|
||||
```json
|
||||
{ "id": 42, "tags": ["city", "night", "cyberpunk"], ... }
|
||||
```
|
||||
|
||||
Filterable: `tags`
|
||||
Searchable: `tags` (full-text match on tag slugs)
|
||||
|
||||
Sync triggered by:
|
||||
- `ArtworkObserver` (created/updated/deleted/restored)
|
||||
- `TagService` — all mutation methods dispatch `IndexArtworkJob`
|
||||
- `ArtworkAwardService::syncToSearch()`
|
||||
|
||||
Rebuild all: `php artisan artworks:search-rebuild`
|
||||
|
||||
---
|
||||
|
||||
## UI Component
|
||||
|
||||
`resources/js/components/tags/TagInput.jsx`
|
||||
|
||||
```jsx
|
||||
<TagInput
|
||||
value={tags} // string[]
|
||||
onChange={setTags} // (string[]) => void
|
||||
suggestedTags={aiTags} // [{ tag, confidence }]
|
||||
maxTags={15}
|
||||
searchEndpoint="/api/tags/search"
|
||||
popularEndpoint="/api/tags/popular"
|
||||
/>
|
||||
```
|
||||
|
||||
**Keyboard shortcuts:**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Enter / Comma | Add current input as tag |
|
||||
| Tab | Accept highlighted suggestion or add input |
|
||||
| Backspace (empty input) | Remove last tag |
|
||||
| Arrow Up/Down | Navigate suggestions |
|
||||
| Escape | Close suggestions |
|
||||
|
||||
Paste splits on commas automatically.
|
||||
|
||||
---
|
||||
|
||||
## Tag Pages (SEO)
|
||||
|
||||
Route: `GET /tag/{slug}`
|
||||
Controller: `TagController@show` (`App\Http\Controllers\Web\TagController`)
|
||||
|
||||
SEO output per page:
|
||||
- `<title>` → `{Tag} Artworks | Skinbase`
|
||||
- `<meta name="description">` → `Browse {count}+ artworks tagged with {tag}.`
|
||||
- `<link rel="canonical">` → `https://skinbase.org/tag/{slug}`
|
||||
- JSON-LD `CollectionPage` schema
|
||||
- Prev/next pagination links
|
||||
- `?sort=popular|latest|liked|downloads` supported
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`config/tags.php`
|
||||
|
||||
```php
|
||||
'max_length' => 32, // max chars per tag slug
|
||||
'max_per_upload'=> 15, // max tags per artwork
|
||||
'banned' => [], // blocked slugs (add to env-driven list)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
PHP: `tests/Feature/TagSystemTest.php`
|
||||
|
||||
Covers: normalization, duplicate prevention, AI attach, sync, usage counts, force-delete cleanup.
|
||||
|
||||
JS: `resources/js/components/tags/TagInput.test.jsx`
|
||||
|
||||
Covers: add/remove, keyboard accept, paste, API failure, max-tags limit.
|
||||
|
||||
Run:
|
||||
```bash
|
||||
php artisan test --filter=TagSystem
|
||||
npm test -- TagInput
|
||||
```
|
||||
101
.deploy/artwork-evolution-release/docs/ui/tag-input.md
Normal file
101
.deploy/artwork-evolution-release/docs/ui/tag-input.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# TagInput UI Component
|
||||
|
||||
## Overview
|
||||
|
||||
`TagInput` is the reusable tag entry component for Skinbase artwork flows.
|
||||
|
||||
It is designed for:
|
||||
|
||||
- Upload page
|
||||
- Artwork edit page
|
||||
- Admin moderation screens
|
||||
|
||||
The component encapsulates all tag UX behavior (chips, search, keyboard flow, AI suggestions, status hints) so pages stay thin.
|
||||
|
||||
## File Location
|
||||
|
||||
- `resources/js/components/tags/TagInput.jsx`
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `string \| string[]` | required | Controlled selected tags. Can be CSV string or array. |
|
||||
| `onChange` | `(tags: string[]) => void` | required | Called whenever selected tags change. |
|
||||
| `suggestedTags` | `Array<string \| object>` | `[]` | AI-suggested tags shown as clickable pills. |
|
||||
| `disabled` | `boolean` | `false` | Disables input and interactions. |
|
||||
| `maxTags` | `number` | `15` | Maximum number of selected tags. |
|
||||
| `minLength` | `number` | `2` | Minimum normalized tag length. |
|
||||
| `maxLength` | `number` | `32` | Maximum normalized tag length. |
|
||||
| `placeholder` | `string` | `Type tags…` | Input placeholder text. |
|
||||
| `searchEndpoint` | `string` | `/api/tags/search` | Search API endpoint. |
|
||||
| `popularEndpoint` | `string` | `/api/tags/popular` | Popular tags endpoint when input is empty. |
|
||||
|
||||
## Normalization
|
||||
|
||||
Tags are normalized client-side before being added:
|
||||
|
||||
- lowercase
|
||||
- trim
|
||||
- spaces → `-`
|
||||
- remove unsupported characters
|
||||
- collapse repeated separators
|
||||
- max length = 32
|
||||
|
||||
Server-side normalization/validation still applies and remains authoritative.
|
||||
|
||||
## Keyboard & Interaction
|
||||
|
||||
- `Enter` → add tag
|
||||
- `Comma` → add tag
|
||||
- `Tab` → accept highlighted suggestion
|
||||
- `Backspace` (empty input) → remove last tag
|
||||
- `Escape` → close suggestion dropdown
|
||||
- Paste CSV (`a, b, c`) → split and add valid tags
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Suggestion dropdown uses `role="listbox"`
|
||||
- Suggestions use `role="option"`
|
||||
- Active item uses `aria-selected`
|
||||
- Input uses `aria-expanded`, `aria-controls`, `aria-autocomplete`
|
||||
|
||||
## API Usage
|
||||
|
||||
The component performs debounced search (300ms):
|
||||
|
||||
- `GET /api/tags/search?q=<query>`
|
||||
- `GET /api/tags/popular` (empty query)
|
||||
|
||||
Behavior:
|
||||
|
||||
- caches recent query results
|
||||
- aborts outdated requests
|
||||
- max 8 suggestions
|
||||
- excludes already-selected tags
|
||||
- shows non-blocking message when search fails
|
||||
|
||||
## Upload Integration Example
|
||||
|
||||
```jsx
|
||||
<TagInput
|
||||
value={state.metadata.tags}
|
||||
onChange={(nextTags) => {
|
||||
dispatch({ type: 'SET_METADATA', payload: { tags: nextTags.join(', ') } })
|
||||
}}
|
||||
suggestedTags={props.suggested_tags || []}
|
||||
maxTags={15}
|
||||
minLength={2}
|
||||
maxLength={32}
|
||||
/>
|
||||
```
|
||||
|
||||
## Events & Save Strategy
|
||||
|
||||
`TagInput` itself does not persist to backend on keystrokes.
|
||||
|
||||
Persistence is done on save/publish boundary by page logic, e.g.:
|
||||
|
||||
- `PUT /api/artworks/{id}/tags`
|
||||
|
||||
This keeps UI responsive and avoids unnecessary API writes.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Upload UI v2 Rollout Runbook
|
||||
|
||||
## Status
|
||||
|
||||
- Upload UI v2 is production-ready.
|
||||
- Feature flag posture: `uploads_v2` default ON.
|
||||
- Emergency override remains available through `SKINBASE_UPLOADS_V2=false`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Route: `/upload`
|
||||
- UI: React/Inertia Upload Wizard v2
|
||||
- API endpoints in use:
|
||||
- `POST /api/uploads/init`
|
||||
- `POST /api/uploads/chunk`
|
||||
- `POST /api/uploads/finish`
|
||||
- `GET /api/uploads/{id}/status`
|
||||
- `POST /api/uploads/{id}/publish`
|
||||
- `POST /api/uploads/cancel`
|
||||
|
||||
## Legacy Flow Policy
|
||||
|
||||
- Current state: legacy upload flow remains in code behind feature flag branch.
|
||||
- Removal decision: **scheduled removal** (not immediate deletion).
|
||||
- Target window: remove legacy branch in the next hardening cycle after stable production operation.
|
||||
- Suggested checkpoint gates before removal:
|
||||
1. 7 consecutive days with no Sev-1/Sev-2 upload regressions.
|
||||
2. Upload completion rate at or above pre-v2 baseline.
|
||||
3. No unresolved blockers in publish/cancel/status polling.
|
||||
|
||||
## Rollout Checklist
|
||||
|
||||
### 1) Staging
|
||||
|
||||
- Set `SKINBASE_UPLOADS_V2=true` in staging env.
|
||||
- Build and deploy current commit.
|
||||
- Verify upload happy paths:
|
||||
- image upload (jpg/png/webp)
|
||||
- archive upload with required screenshots
|
||||
- cancel in-progress upload
|
||||
- publish after ready state
|
||||
- Verify failure paths:
|
||||
- invalid file type
|
||||
- over-size files
|
||||
- processing/publish API failure surfaces retry/reset correctly
|
||||
- Verify analytics events emitted in browser:
|
||||
- `upload_start`
|
||||
- `upload_complete`
|
||||
- `upload_publish`
|
||||
- `upload_cancel`
|
||||
- `upload_error`
|
||||
|
||||
### 2) Production Enablement
|
||||
|
||||
- Confirm production env has `SKINBASE_UPLOADS_V2=true` (or unset, default ON).
|
||||
- Deploy release artifact.
|
||||
- Run smoke tests on `/upload` with one image and one archive flow.
|
||||
- Confirm endpoints respond with expected status codes under normal load.
|
||||
|
||||
### 3) Post-Deploy Verification (0-24h)
|
||||
|
||||
- Validate build artifact and route rendering:
|
||||
- `/upload` renders v2 wizard UI
|
||||
- no front-end boot errors in browser console
|
||||
- Validate pipeline behavior:
|
||||
- init/chunk/finish/status/publish/cancel all reachable
|
||||
- status polling transitions to ready/publishable where expected
|
||||
- Validate user outcomes:
|
||||
- completion and publish rates are stable vs prior day baseline
|
||||
- no spike in cancellation due to UI confusion
|
||||
|
||||
## Post-Deploy Monitoring Plan
|
||||
|
||||
### Key Metrics
|
||||
|
||||
- Upload start volume (`upload_start`)
|
||||
- Upload completion volume (`upload_complete`)
|
||||
- Publish success volume (`upload_publish`)
|
||||
- Error volume by stage (`upload_error.stage`)
|
||||
- Cancel volume (`upload_cancel`)
|
||||
- Derived funnel:
|
||||
- start -> complete conversion
|
||||
- complete -> publish conversion
|
||||
- overall start -> publish conversion
|
||||
|
||||
### Operational Signals
|
||||
|
||||
- API error rates for `/api/uploads/*`
|
||||
- p95 latency for `init`, `chunk`, `finish`, `status`, `publish`
|
||||
- 4xx/5xx split by endpoint
|
||||
- Client-side uncaught exceptions on `/upload`
|
||||
|
||||
### Alert Thresholds (initial)
|
||||
|
||||
- Critical rollback candidate:
|
||||
- `upload_error` rate > 2x baseline for 15+ minutes, or
|
||||
- publish failure rate > 5% sustained for 15+ minutes, or
|
||||
- any endpoint 5xx rate > 3% sustained for 10+ minutes.
|
||||
- Warning/observe:
|
||||
- completion funnel drops > 10% vs trailing 7-day average.
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
### Fast Toggle Rollback (preferred)
|
||||
|
||||
1. Set `SKINBASE_UPLOADS_V2=false`.
|
||||
2. Reload config/cache per deploy process.
|
||||
3. Verify `/upload` serves legacy flow.
|
||||
4. Continue API monitoring until error rates normalize.
|
||||
|
||||
### Release Rollback (if needed)
|
||||
|
||||
1. Roll back to prior release artifact.
|
||||
2. Keep `SKINBASE_UPLOADS_V2=false` during stabilization.
|
||||
3. Re-run smoke test for upload + publish.
|
||||
|
||||
### Communication
|
||||
|
||||
- Post incident update in release channel with:
|
||||
- start time
|
||||
- impact scope (upload, publish, cancel)
|
||||
- rollback action taken
|
||||
- follow-up issue link
|
||||
|
||||
## Ownership and Next Actions
|
||||
|
||||
- Owner: Upload frontend + API maintainers.
|
||||
- First review checkpoint: 24h post deploy.
|
||||
- Second checkpoint: 7 days post deploy for legacy removal go/no-go.
|
||||
- If metrics remain healthy, create removal PR for legacy branch in `/upload` page component.
|
||||
Reference in New Issue
Block a user