Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View 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`

View 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.

View 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

View File

@@ -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.

View File

@@ -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()`.

View 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.

View File

@@ -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`.

View 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.

View 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.

View 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.

View File

@@ -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.

View 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;

View File

@@ -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)`

View File

@@ -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 studios 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.

View File

@@ -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

View 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.

View File

@@ -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:0003: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;
```

View 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.

View 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

View File

@@ -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

View File

@@ -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.

View File

@@ -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 creators 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

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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.

View 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`

View File

@@ -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

View 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
```

View 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.

View File

@@ -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.