Compare commits
63 Commits
master
...
73260e7eae
| Author | SHA1 | Date | |
|---|---|---|---|
| 73260e7eae | |||
| 2608be7420 | |||
| e8b5edf5d2 | |||
| 60f78e8235 | |||
| 979e011257 | |||
| 29c3ff8572 | |||
| 1a62fcb81d | |||
| 7da0fd39f7 | |||
| 7b37259a2c | |||
| 2119741ba7 | |||
| 2728644477 | |||
| b3fc889452 | |||
| 980a15f66e | |||
| 78151aabfe | |||
| 4f576ceb04 | |||
| 547215cbe8 | |||
| 23b813bbff | |||
| f6772f673b | |||
| 5a33ca55a1 | |||
| b9c2d8597d | |||
| dc51d65440 | |||
| 1266f81d35 | |||
| a875203482 | |||
| e3ca845a6d | |||
| 211dc58884 | |||
| 916bb29a53 | |||
| de3ec22ee5 | |||
| 90f244f264 | |||
| 568b3f3abb | |||
| eee7df1f8c | |||
| 80100c7651 | |||
| 8b00084f09 | |||
| 6536d4ae78 | |||
| 67ef79766c | |||
| 09eadf9003 | |||
| 4f9b43bbba | |||
| f0cca76eb3 | |||
| 15b7b77d20 | |||
| d0aefc5ddc | |||
| d3fd32b004 | |||
| 0032aec02f | |||
| 5c97488e80 | |||
| 48e2055b6a | |||
| e4e0bdf8f1 | |||
| 7648e7d426 | |||
| e70a876ef2 | |||
| df67252078 | |||
| b239af9619 | |||
| 4fb95c872b | |||
| 795c7a835f | |||
| 93b009d42a | |||
| c30fa5a392 | |||
| 8935065af1 | |||
| 41287914aa | |||
| b053c0cc48 | |||
| 7dbfdab40e | |||
| 7734e53d87 | |||
| d114472823 | |||
| b2c9efe587 | |||
| 9dbe848412 | |||
| 79192345e3 | |||
| e129618910 | |||
| f04854bb8d |
49
.copilot/categories-analysis.md
Normal file
49
.copilot/categories-analysis.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
Current DB & Models Analysis — 2026-02-10
|
||||||
|
|
||||||
|
Summary
|
||||||
|
- `content_types` is the master namespace (see screenshot). Rows: e.g. id=1 Photography (slug `photography`), id=2 Wallpapers, id=3 Skins, id=544 Members.
|
||||||
|
- `categories` references `content_types` through `content_type_id`; hierarchical parent/child relation via `parent_id`.
|
||||||
|
|
||||||
|
Observed DB columns (categories)
|
||||||
|
- id, content_type_id, parent_id, name, slug, description, image, is_active, sort_order, created_at, updated_at, deleted_at
|
||||||
|
|
||||||
|
Models verified
|
||||||
|
- `ContentType` (app/Models/ContentType.php)
|
||||||
|
- hasMany `categories()` and `rootCategories()`
|
||||||
|
- uses `slug` for route binding
|
||||||
|
- Status: OK and aligns with DB
|
||||||
|
|
||||||
|
- `Category` (app/Models/Category.php)
|
||||||
|
- belongsTo `contentType()`
|
||||||
|
- self-referential `parent()` / `children()` (ordered by `sort_order`, then `name`)
|
||||||
|
- `descendants()` recursive helper
|
||||||
|
- `seo()` relation, `artworks()` pivot
|
||||||
|
- scopes: `active()`, `roots()`
|
||||||
|
- accessors: `full_slug_path`, `url`, `canonical_url`, `breadcrumbs`
|
||||||
|
- slug validation enforced in `boot()` (lowercase; only a-z0-9- and dashes)
|
||||||
|
- Status: OK and consistent with screenshots
|
||||||
|
|
||||||
|
Key behaviors and checks
|
||||||
|
- URL formation: `$category->url` -> `/{content_type.slug}/{category_path}`; canonical URL -> `https://skinbase.org{url}`
|
||||||
|
- Slug policy: generation with `Str::slug()` + model validation. Do not bypass.
|
||||||
|
- Ordering: use `children()` (sort_order then name) for deterministic menus.
|
||||||
|
- Soft deletes: model uses `SoftDeletes`; be explicit when you need trashed categories.
|
||||||
|
- Eager-loading: `full_slug_path` walks parents — eager-load `parent` (or ancestors) to avoid N+1 when computing multiple paths.
|
||||||
|
|
||||||
|
Copilot / Dev rules (short checklist)
|
||||||
|
- Always look up content types by `slug`, not by numeric ID.
|
||||||
|
- Use `->roots()->active()->with('children')` for public category lists.
|
||||||
|
- Use `$category->url` and `$category->canonical_url` for links and canonical tags.
|
||||||
|
- Maintain slug rules: lowercase, only `a-z0-9-`.
|
||||||
|
- When reparenting categories, consider invalidating any cached derived paths for descendants.
|
||||||
|
- Avoid using legacy `artworks_categories` directly in new controllers; create an adapter if you must read old data.
|
||||||
|
|
||||||
|
Suggested next steps
|
||||||
|
- Add a PHPUnit test asserting slug validation and `url` generation for nested categories.
|
||||||
|
- (Optional) Generate a small ER diagram showing `content_types -> categories -> artwork_category`.
|
||||||
|
|
||||||
|
Files referenced
|
||||||
|
- [app/Models/ContentType.php](app/Models/ContentType.php)
|
||||||
|
- [app/Models/Category.php](app/Models/Category.php)
|
||||||
|
|
||||||
|
If you want, I can now add the PHPUnit test or generate the ER diagram.
|
||||||
8
.env.cpad
Normal file
8
.env.cpad
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# cPad Configuration
|
||||||
|
# Template: custom
|
||||||
|
|
||||||
|
CPAD_DEBUG=false
|
||||||
|
CPAD_CACHE_ENABLED=true
|
||||||
|
CPAD_LOG_LEVEL=WARNING
|
||||||
|
CPAD_SECURITY_LEVEL=MAXIMUM
|
||||||
|
CPAD_BACKUP_ENABLED=true
|
||||||
281
.env.example
281
.env.example
@@ -41,9 +41,223 @@ SESSION_ENCRYPT=false
|
|||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=reverb
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=redis
|
||||||
|
|
||||||
|
MESSAGING_REALTIME=true
|
||||||
|
MESSAGING_BROADCAST_QUEUE=broadcasts
|
||||||
|
MESSAGING_TYPING_TTL=8
|
||||||
|
MESSAGING_TYPING_CACHE_STORE=redis
|
||||||
|
MESSAGING_PRESENCE_TTL=90
|
||||||
|
MESSAGING_CONVERSATION_PRESENCE_TTL=45
|
||||||
|
MESSAGING_PRESENCE_CACHE_STORE=redis
|
||||||
|
MESSAGING_RECOVERY_MAX_MESSAGES=100
|
||||||
|
MESSAGING_OFFLINE_FALLBACK_ONLY=true
|
||||||
|
|
||||||
|
HORIZON_NAME=skinbase-nova
|
||||||
|
HORIZON_PATH=horizon
|
||||||
|
HORIZON_PREFIX=skinbase_nova_horizon:
|
||||||
|
|
||||||
|
REVERB_APP_ID=skinbase-local
|
||||||
|
REVERB_APP_KEY=skinbase-local-key
|
||||||
|
REVERB_APP_SECRET=skinbase-local-secret
|
||||||
|
REVERB_HOST=127.0.0.1
|
||||||
|
REVERB_PORT=8080
|
||||||
|
REVERB_SCHEME=http
|
||||||
|
REVERB_SERVER_HOST=0.0.0.0
|
||||||
|
REVERB_SERVER_PORT=8080
|
||||||
|
REVERB_SERVER_PATH=
|
||||||
|
REVERB_SCALING_ENABLED=false
|
||||||
|
|
||||||
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||||
|
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||||
|
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||||
|
|
||||||
|
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
|
||||||
|
SKINBASE_UPLOADS_V2=false
|
||||||
|
|
||||||
|
# Draft abuse prevention controls
|
||||||
|
SKINBASE_MAX_DRAFTS=10
|
||||||
|
SKINBASE_MAX_DRAFT_STORAGE_MB=1024
|
||||||
|
SKINBASE_DUPLICATE_HASH_POLICY=block
|
||||||
|
|
||||||
|
# Vision / AI auto-tagging (local defaults)
|
||||||
|
VISION_ENABLED=true
|
||||||
|
VISION_QUEUE=default
|
||||||
|
VISION_IMAGE_VARIANT=md
|
||||||
|
VISION_GATEWAY_URL=
|
||||||
|
VISION_GATEWAY_TIMEOUT=10
|
||||||
|
VISION_GATEWAY_CONNECT_TIMEOUT=3
|
||||||
|
VISION_VECTOR_GATEWAY_ENABLED=true
|
||||||
|
VISION_VECTOR_GATEWAY_URL=
|
||||||
|
VISION_VECTOR_GATEWAY_API_KEY=
|
||||||
|
VISION_VECTOR_GATEWAY_COLLECTION=images
|
||||||
|
VISION_VECTOR_GATEWAY_TIMEOUT=20
|
||||||
|
VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT=5
|
||||||
|
VISION_VECTOR_GATEWAY_RETRIES=1
|
||||||
|
VISION_VECTOR_GATEWAY_RETRY_DELAY_MS=250
|
||||||
|
VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT=/vectors/upsert
|
||||||
|
VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT=/vectors/search
|
||||||
|
VISION_VECTOR_GATEWAY_DELETE_ENDPOINT=/vectors/delete
|
||||||
|
VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT=/vectors/collections
|
||||||
|
|
||||||
|
# CLIP service (set base URL to enable CLIP calls)
|
||||||
|
CLIP_BASE_URL=
|
||||||
|
CLIP_ANALYZE_ENDPOINT=/analyze
|
||||||
|
CLIP_TIMEOUT_SECONDS=8
|
||||||
|
CLIP_CONNECT_TIMEOUT_SECONDS=2
|
||||||
|
CLIP_HTTP_RETRIES=1
|
||||||
|
CLIP_HTTP_RETRY_DELAY_MS=200
|
||||||
|
CLIP_EMBED_ENDPOINT=/embed
|
||||||
|
CLIP_EMBED_TIMEOUT_SECONDS=8
|
||||||
|
CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=2
|
||||||
|
CLIP_EMBED_HTTP_RETRIES=1
|
||||||
|
CLIP_EMBED_HTTP_RETRY_DELAY_MS=200
|
||||||
|
|
||||||
|
# Similar artworks / embedding pipeline
|
||||||
|
RECOMMENDATIONS_QUEUE=${VISION_QUEUE}
|
||||||
|
RECOMMENDATIONS_EMBEDDING_ENABLED=true
|
||||||
|
RECOMMENDATIONS_EMBEDDING_MODEL=clip
|
||||||
|
RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1
|
||||||
|
RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1
|
||||||
|
RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
|
||||||
|
RECOMMENDATIONS_MIN_DIM=64
|
||||||
|
RECOMMENDATIONS_MAX_DIM=4096
|
||||||
|
RECOMMENDATIONS_BACKFILL_BATCH=200
|
||||||
|
SIMILARITY_VECTOR_ENABLED=false
|
||||||
|
SIMILARITY_VECTOR_ADAPTER=pgvector
|
||||||
|
|
||||||
|
# Personalized discovery foundation (Phase 8)
|
||||||
|
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
|
||||||
|
DISCOVERY_PROFILE_VERSION=profile-v1
|
||||||
|
DISCOVERY_EVENT_VERSION=event-v1
|
||||||
|
DISCOVERY_ALGO_VERSION=${RECOMMENDATIONS_ALGO_VERSION}
|
||||||
|
DISCOVERY_CACHE_VERSION=cache-v1
|
||||||
|
DISCOVERY_DECAY_HALF_LIFE_HOURS=72
|
||||||
|
DISCOVERY_WEIGHT_VIEW=1
|
||||||
|
DISCOVERY_WEIGHT_CLICK=2
|
||||||
|
DISCOVERY_WEIGHT_FAVORITE=4
|
||||||
|
DISCOVERY_WEIGHT_DOWNLOAD=3
|
||||||
|
DISCOVERY_CACHE_TTL_MINUTES=60
|
||||||
|
DISCOVERY_V3_ENABLED=false
|
||||||
|
DISCOVERY_V3_CACHE_VERSION=cache-v3
|
||||||
|
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
|
||||||
|
DISCOVERY_V3_SECTION_SIMILAR_STYLE_LIMIT=3
|
||||||
|
DISCOVERY_V3_SECTION_YOU_MAY_ALSO_LIKE_LIMIT=6
|
||||||
|
DISCOVERY_V3_SECTION_VISUALLY_RELATED_LIMIT=6
|
||||||
|
DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
|
||||||
|
DISCOVERY_RANKING_W1=0.65
|
||||||
|
DISCOVERY_RANKING_W2=0.20
|
||||||
|
DISCOVERY_RANKING_W3=0.10
|
||||||
|
DISCOVERY_RANKING_W4=0.05
|
||||||
|
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V1=rank-w-v1
|
||||||
|
DISCOVERY_RANKING_W1_CLIP_COSINE_V1=0.65
|
||||||
|
DISCOVERY_RANKING_W2_CLIP_COSINE_V1=0.20
|
||||||
|
DISCOVERY_RANKING_W3_CLIP_COSINE_V1=0.10
|
||||||
|
DISCOVERY_RANKING_W4_CLIP_COSINE_V1=0.05
|
||||||
|
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V2=rank-w-v2-prod-1
|
||||||
|
DISCOVERY_RANKING_W1_CLIP_COSINE_V2=0.52
|
||||||
|
DISCOVERY_RANKING_W2_CLIP_COSINE_V2=0.23
|
||||||
|
DISCOVERY_RANKING_W3_CLIP_COSINE_V2=0.15
|
||||||
|
DISCOVERY_RANKING_W4_CLIP_COSINE_V2=0.10
|
||||||
|
DISCOVERY_ROLLOUT_ENABLED=false
|
||||||
|
DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION=clip-cosine-v1
|
||||||
|
DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION=clip-cosine-v2
|
||||||
|
DISCOVERY_ROLLOUT_ACTIVE_GATE=g10
|
||||||
|
DISCOVERY_ROLLOUT_GATE_10_PERCENT=10
|
||||||
|
DISCOVERY_ROLLOUT_GATE_50_PERCENT=50
|
||||||
|
DISCOVERY_ROLLOUT_GATE_100_PERCENT=100
|
||||||
|
DISCOVERY_FORCE_ALGO_VERSION=
|
||||||
|
DISCOVERY_ROLLOUT_WARN_CTR_DROP_PCT=3
|
||||||
|
DISCOVERY_ROLLOUT_ROLLBACK_CTR_DROP_PCT=5
|
||||||
|
DISCOVERY_ROLLOUT_WARN_LONG_DWELL_DROP_PCT=4
|
||||||
|
DISCOVERY_ROLLOUT_ROLLBACK_LONG_DWELL_DROP_PCT=8
|
||||||
|
DISCOVERY_ROLLOUT_WARN_DIVERSITY_CONCENTRATION_RISE_PCT=10
|
||||||
|
DISCOVERY_ROLLOUT_ROLLBACK_DIVERSITY_CONCENTRATION_RISE_PCT=15
|
||||||
|
DISCOVERY_EVAL_WEIGHT_CTR=0.45
|
||||||
|
DISCOVERY_EVAL_WEIGHT_SAVE_RATE=0.35
|
||||||
|
DISCOVERY_EVAL_WEIGHT_LONG_DWELL=0.25
|
||||||
|
DISCOVERY_EVAL_WEIGHT_BOUNCE_PENALTY=0.15
|
||||||
|
DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true
|
||||||
|
|
||||||
|
# YOLO service (optional)
|
||||||
|
YOLO_ENABLED=true
|
||||||
|
YOLO_BASE_URL=
|
||||||
|
YOLO_ANALYZE_ENDPOINT=/analyze
|
||||||
|
YOLO_TIMEOUT_SECONDS=8
|
||||||
|
YOLO_CONNECT_TIMEOUT_SECONDS=2
|
||||||
|
YOLO_HTTP_RETRIES=1
|
||||||
|
YOLO_HTTP_RETRY_DELAY_MS=200
|
||||||
|
YOLO_PHOTOGRAPHY_ONLY=true
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Production examples (uncomment and adjust)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# VISION_ENABLED=true
|
||||||
|
# VISION_QUEUE=vision
|
||||||
|
# VISION_IMAGE_VARIANT=md
|
||||||
|
# VISION_GATEWAY_URL=https://vision.internal
|
||||||
|
# VISION_GATEWAY_TIMEOUT=8
|
||||||
|
# VISION_GATEWAY_CONNECT_TIMEOUT=2
|
||||||
|
#
|
||||||
|
# CLIP_BASE_URL=https://clip.internal
|
||||||
|
# CLIP_ANALYZE_ENDPOINT=/analyze
|
||||||
|
# CLIP_TIMEOUT_SECONDS=5
|
||||||
|
# CLIP_CONNECT_TIMEOUT_SECONDS=1
|
||||||
|
# CLIP_HTTP_RETRIES=1
|
||||||
|
# CLIP_HTTP_RETRY_DELAY_MS=150
|
||||||
|
# CLIP_EMBED_ENDPOINT=/embed
|
||||||
|
# CLIP_EMBED_TIMEOUT_SECONDS=5
|
||||||
|
# CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=1
|
||||||
|
# CLIP_EMBED_HTTP_RETRIES=1
|
||||||
|
# CLIP_EMBED_HTTP_RETRY_DELAY_MS=150
|
||||||
|
# RECOMMENDATIONS_QUEUE=vision
|
||||||
|
# RECOMMENDATIONS_EMBEDDING_ENABLED=true
|
||||||
|
# RECOMMENDATIONS_EMBEDDING_MODEL=clip
|
||||||
|
# RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1
|
||||||
|
# RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1
|
||||||
|
# RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1,clip-cosine-v2
|
||||||
|
# RECOMMENDATIONS_BACKFILL_BATCH=250
|
||||||
|
# DISCOVERY_QUEUE=vision
|
||||||
|
# DISCOVERY_PROFILE_VERSION=profile-v1
|
||||||
|
# DISCOVERY_EVENT_VERSION=event-v1
|
||||||
|
# DISCOVERY_ALGO_VERSION=clip-cosine-v1
|
||||||
|
# DISCOVERY_CACHE_VERSION=cache-v1
|
||||||
|
# DISCOVERY_DECAY_HALF_LIFE_HOURS=72
|
||||||
|
# DISCOVERY_WEIGHT_VIEW=1
|
||||||
|
# DISCOVERY_WEIGHT_CLICK=2
|
||||||
|
# DISCOVERY_WEIGHT_FAVORITE=4
|
||||||
|
# DISCOVERY_WEIGHT_DOWNLOAD=3
|
||||||
|
# DISCOVERY_V3_ENABLED=true
|
||||||
|
# DISCOVERY_V3_CACHE_VERSION=cache-v3
|
||||||
|
# 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
|
||||||
|
# DISCOVERY_V3_SECTION_SIMILAR_STYLE_LIMIT=3
|
||||||
|
# DISCOVERY_V3_SECTION_YOU_MAY_ALSO_LIKE_LIMIT=6
|
||||||
|
# DISCOVERY_V3_SECTION_VISUALLY_RELATED_LIMIT=6
|
||||||
|
# DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
|
||||||
|
# DISCOVERY_RANKING_W1=0.65
|
||||||
|
# DISCOVERY_RANKING_W2=0.20
|
||||||
|
# DISCOVERY_RANKING_W3=0.10
|
||||||
|
# DISCOVERY_RANKING_W4=0.05
|
||||||
|
#
|
||||||
|
# YOLO_ENABLED=true
|
||||||
|
# YOLO_BASE_URL=https://yolo.internal
|
||||||
|
# YOLO_ANALYZE_ENDPOINT=/analyze
|
||||||
|
# YOLO_TIMEOUT_SECONDS=5
|
||||||
|
# YOLO_CONNECT_TIMEOUT_SECONDS=1
|
||||||
|
# YOLO_HTTP_RETRIES=1
|
||||||
|
# YOLO_HTTP_RETRY_DELAY_MS=150
|
||||||
|
# YOLO_PHOTOGRAPHY_ONLY=true
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
@@ -64,6 +278,23 @@ MAIL_PASSWORD=null
|
|||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# Registration anti-spam
|
||||||
|
REGISTRATION_IP_PER_MINUTE_LIMIT=3
|
||||||
|
REGISTRATION_IP_PER_DAY_LIMIT=20
|
||||||
|
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
|
||||||
|
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
|
||||||
|
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
|
||||||
|
REGISTRATION_ENABLE_TURNSTILE=true
|
||||||
|
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
|
||||||
|
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
|
||||||
|
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
|
||||||
|
REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
|
||||||
|
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
||||||
|
TURNSTILE_TIMEOUT=5
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
@@ -71,3 +302,49 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# ─── Early-Stage Growth System ───────────────────────────────────────────────
|
||||||
|
# Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to normal behaviour.
|
||||||
|
# NOVA_EARLY_GROWTH_MODE: off | light | aggressive
|
||||||
|
NOVA_EARLY_GROWTH_ENABLED=false
|
||||||
|
NOVA_EARLY_GROWTH_MODE=off
|
||||||
|
|
||||||
|
# Module toggles (only active when NOVA_EARLY_GROWTH_ENABLED=true)
|
||||||
|
NOVA_EGS_ADAPTIVE_WINDOW=true
|
||||||
|
NOVA_EGS_GRID_FILLER=true
|
||||||
|
NOVA_EGS_SPOTLIGHT=true
|
||||||
|
NOVA_EGS_ACTIVITY_LAYER=false
|
||||||
|
|
||||||
|
# AdaptiveTimeWindow thresholds
|
||||||
|
NOVA_EGS_UPLOADS_PER_DAY_NARROW=10
|
||||||
|
NOVA_EGS_UPLOADS_PER_DAY_WIDE=3
|
||||||
|
NOVA_EGS_WINDOW_NARROW_DAYS=7
|
||||||
|
NOVA_EGS_WINDOW_MEDIUM_DAYS=30
|
||||||
|
NOVA_EGS_WINDOW_WIDE_DAYS=90
|
||||||
|
|
||||||
|
# GridFiller minimum items per page
|
||||||
|
NOVA_EGS_GRID_MIN_RESULTS=12
|
||||||
|
|
||||||
|
# Auto-disable when site reaches organic scale
|
||||||
|
NOVA_EGS_AUTO_DISABLE=false
|
||||||
|
NOVA_EGS_AUTO_DISABLE_UPLOADS=50
|
||||||
|
NOVA_EGS_AUTO_DISABLE_USERS=500
|
||||||
|
|
||||||
|
# Cache TTLs (seconds)
|
||||||
|
NOVA_EGS_SPOTLIGHT_TTL=3600
|
||||||
|
NOVA_EGS_BLEND_TTL=300
|
||||||
|
NOVA_EGS_WINDOW_TTL=600
|
||||||
|
NOVA_EGS_ACTIVITY_TTL=1800
|
||||||
|
# ─── OAuth / Social Login ─────────────────────────────────────────────────────
|
||||||
|
# Google — https://console.cloud.google.com/apis/credentials
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_REDIRECT_URI=/auth/google/callback
|
||||||
|
|
||||||
|
# Discord — https://discord.com/developers/applications
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
DISCORD_REDIRECT_URI=/auth/discord/callback
|
||||||
|
|
||||||
|
# Apple — https://developer.apple.com/account/resources/identifiers/list/serviceId
|
||||||
|
# Apple sign in removed
|
||||||
|
|||||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -16,11 +16,40 @@
|
|||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/public/files
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/storage/app
|
||||||
|
/storage/framework/cache
|
||||||
|
/storage/framework/sessions
|
||||||
|
/storage/framework/views
|
||||||
|
/storage/logs
|
||||||
|
/storage/testing
|
||||||
|
/storage/*.log
|
||||||
|
/storage/*.key
|
||||||
|
/storage/*.sqlite
|
||||||
|
/storage/*.sqlite3
|
||||||
|
/storage/*.zip
|
||||||
|
/storage/*.tar.gz
|
||||||
|
/storage/*.tar.gz
|
||||||
|
/storage/*.tar.bz2
|
||||||
|
/storage/*.tar.xz
|
||||||
|
/storage/*.tar
|
||||||
|
/storage/*.tgz
|
||||||
|
/storage/*.tbz2
|
||||||
|
/storage/*.txz
|
||||||
|
/storage/*.zip
|
||||||
|
/storage/*.tar.gz
|
||||||
|
/storage/*.tar.bz2
|
||||||
|
/storage/*.tar.xz
|
||||||
|
/storage/*.tar
|
||||||
|
/storage/*.tgz
|
||||||
/vendor
|
/vendor
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/oldSite/*
|
/oldSite/*
|
||||||
oldSite
|
oldSite
|
||||||
|
packages
|
||||||
|
/packages/*
|
||||||
|
/public/admin/*
|
||||||
|
|||||||
43
PR_REGISTRATION_ANTISPAM.md
Normal file
43
PR_REGISTRATION_ANTISPAM.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# PR Title
|
||||||
|
feat(auth): complete registration anti-spam + email quota protection
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Implements the registration anti-spam and quota hardening spec end-to-end for the email-first onboarding flow.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
- Added registration anti-spam config and disposable domain config.
|
||||||
|
- Added progressive Turnstile verification service and wiring.
|
||||||
|
- Added registration rate limiters and route middleware (`register-ip`, `register-ip-daily`).
|
||||||
|
- Implemented per-email cooldown and generic anti-enumeration responses.
|
||||||
|
- Added queued verification sending job with global throttle + quota circuit breaker.
|
||||||
|
- Added quota and disposable-domain services.
|
||||||
|
- Hardened verification tokens (hashed storage lookup, expiry, one-time use).
|
||||||
|
- Added/updated migrations:
|
||||||
|
- cooldown fields on `users`
|
||||||
|
- `email_send_events`
|
||||||
|
- `system_email_quota`
|
||||||
|
- token column hardening (`token` -> `token_hash`)
|
||||||
|
- rollout safety migration to ensure `user_verification_tokens` table exists
|
||||||
|
- Added models: `EmailSendEvent`, `SystemEmailQuota`.
|
||||||
|
- Added/updated auth registration tests and runbook docs.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `php artisan migrate` ✅
|
||||||
|
- `php artisan test` ✅
|
||||||
|
- Focused token hardening tests ✅ (`RegistrationTokenVerificationTest`)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Current local branch: `feat/registration-antispam-complete`
|
||||||
|
- Local commit: `b239af9`
|
||||||
|
- Push/PR creation is currently blocked because this repo has no configured git remote and `gh` CLI is not installed.
|
||||||
|
|
||||||
|
## Commands to finish PR after remote setup
|
||||||
|
```bash
|
||||||
|
git remote add origin <your-repo-url>
|
||||||
|
git push -u origin feat/registration-antispam-complete
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open PR in your Git host UI using:
|
||||||
|
- Base: `main` (or your default branch)
|
||||||
|
- Compare: `feat/registration-antispam-complete`
|
||||||
|
- Body: copy this file
|
||||||
382
README.md
382
README.md
@@ -54,6 +54,388 @@ In order to ensure that the Laravel community is welcoming to all, please review
|
|||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
|
## Vision & AI Auto-Tagging Integration
|
||||||
|
|
||||||
|
## Upload UI Feature Flag (`uploads.v2`)
|
||||||
|
|
||||||
|
The new React upload wizard is behind a feature flag and is **disabled by default**.
|
||||||
|
|
||||||
|
- Flag env var: `SKINBASE_UPLOADS_V2`
|
||||||
|
- Config key: `features.uploads_v2`
|
||||||
|
- Client flags source: `window.SKINBASE_FLAGS`
|
||||||
|
|
||||||
|
### Default behavior
|
||||||
|
|
||||||
|
- `SKINBASE_UPLOADS_V2=false` → legacy upload UI is rendered.
|
||||||
|
- `SKINBASE_UPLOADS_V2=true` → `UploadWizard` is rendered.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
In `.env` (or `.env.example` for project defaults):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SKINBASE_UPLOADS_V2=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable explicitly when ready:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SKINBASE_UPLOADS_V2=true
|
||||||
|
```
|
||||||
|
|
||||||
|
After changing env values, clear/reload config as usual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan config:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
The system intentionally keeps legacy upload as the default until the flag is explicitly turned on.
|
||||||
|
|
||||||
|
## Upload Moderation UI Flow
|
||||||
|
|
||||||
|
Admin moderation for draft uploads is available through a dedicated queue page.
|
||||||
|
|
||||||
|
- Page route: `/admin/uploads/moderation`
|
||||||
|
- Access: authenticated users with `role=admin` or `role=moderator`
|
||||||
|
- Data source: `GET /api/admin/uploads/pending`
|
||||||
|
|
||||||
|
### Queue behavior
|
||||||
|
|
||||||
|
1. The page loads pending draft uploads (`moderation_status=pending`).
|
||||||
|
2. Moderators can enter an optional note per upload.
|
||||||
|
3. Approve action calls:
|
||||||
|
|
||||||
|
- `POST /api/admin/uploads/{id}/approve`
|
||||||
|
- Sets moderation to approved and records moderator + timestamp.
|
||||||
|
|
||||||
|
4. Reject action calls:
|
||||||
|
|
||||||
|
- `POST /api/admin/uploads/{id}/reject`
|
||||||
|
- Sets upload status/processing state to rejected and stores note.
|
||||||
|
|
||||||
|
### Publish gate
|
||||||
|
|
||||||
|
- Normal users can publish only when `moderation_status=approved`.
|
||||||
|
- Admin users can publish with override behavior.
|
||||||
|
|
||||||
|
## Similar Artworks Analytics (A/B Evaluation)
|
||||||
|
|
||||||
|
The artwork page similar-items block emits two event types:
|
||||||
|
|
||||||
|
- `impression` (block rendered)
|
||||||
|
- `click` (item clicked)
|
||||||
|
|
||||||
|
Events are stored in `similar_artwork_events` and aggregated daily into `similar_artwork_daily_metrics` by `algo_version`.
|
||||||
|
|
||||||
|
- Ingest endpoint: `POST /api/analytics/similar-artworks`
|
||||||
|
- Aggregation command: `php artisan analytics:aggregate-similar-artworks --date=YYYY-MM-DD`
|
||||||
|
- Scheduler: runs daily at `03:10`
|
||||||
|
|
||||||
|
## Personalized Discovery Foundation (Phase 8)
|
||||||
|
|
||||||
|
This foundation adds versioned, async-only ingestion and profile normalization for personalized discovery.
|
||||||
|
|
||||||
|
- Tables:
|
||||||
|
- `user_interest_profiles`
|
||||||
|
- `user_discovery_events`
|
||||||
|
- `user_recommendation_cache`
|
||||||
|
- Ingest endpoint: `POST /api/discovery/events` (auth required)
|
||||||
|
- Supported event types: `view`, `click`, `favorite`, `download`
|
||||||
|
- Processing model: non-blocking queue job (`IngestUserDiscoveryEventJob`)
|
||||||
|
- Normalization: recency-decay + score normalization in `UserInterestProfileService`
|
||||||
|
|
||||||
|
No feed ranking/UI behavior is introduced in this foundation step.
|
||||||
|
|
||||||
|
### Feed Endpoint Skeleton
|
||||||
|
|
||||||
|
The backend now exposes a personalized feed API skeleton:
|
||||||
|
|
||||||
|
- Endpoint: `GET /api/v1/feed` (auth required)
|
||||||
|
- Query params:
|
||||||
|
- `limit` (1-50, default 24)
|
||||||
|
- `cursor` (opaque cursor token for pagination)
|
||||||
|
- `algo_version` (optional override)
|
||||||
|
- Response includes `data` items and `meta.next_cursor` for cursor pagination.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Reads `user_recommendation_cache` by `user_id + algo_version`.
|
||||||
|
- On cache miss/stale, returns immediate fallback results and dispatches async regeneration job.
|
||||||
|
- Regeneration runs in queue (`RegenerateUserRecommendationCacheJob`) and writes refreshed cache.
|
||||||
|
- Includes cold-start fallback (`popular + similar`) and a diversity guard to avoid near-duplicates.
|
||||||
|
|
||||||
|
## Feed Analytics Instrumentation
|
||||||
|
|
||||||
|
Feed analytics now track:
|
||||||
|
|
||||||
|
- `feed_impression`
|
||||||
|
- `feed_click`
|
||||||
|
|
||||||
|
Payload dimensions:
|
||||||
|
|
||||||
|
- `user_id` (derived from auth session)
|
||||||
|
- `artwork_id`
|
||||||
|
- `position`
|
||||||
|
- `algo_version`
|
||||||
|
- `source` (`personalized`, `cold_start`, `fallback`)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `dwell_seconds` (for click dwell bucket metrics)
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- Ingest: `POST /api/analytics/feed` (auth required)
|
||||||
|
- Daily aggregation: `php artisan analytics:aggregate-feed --date=YYYY-MM-DD`
|
||||||
|
- Admin report: `GET /api/admin/reports/feed-performance`
|
||||||
|
|
||||||
|
Daily metrics include CTR, save-rate, and dwell buckets.
|
||||||
|
|
||||||
|
For non-blocking client transport, use `navigator.sendBeacon` with `fetch(..., { keepalive: true })` fallback.
|
||||||
|
Reference helper: `resources/js/lib/feedAnalytics.js`.
|
||||||
|
|
||||||
|
## Phase 8B: Ranking Weight Tuning (Manual + Data-Driven)
|
||||||
|
|
||||||
|
Discovery ranking now supports versioned blend weights per `algo_version` in `config/discovery.php`.
|
||||||
|
|
||||||
|
- Blend terms: `w1` interest, `w2` recency, `w3` popularity, `w4` novelty
|
||||||
|
- Per-algo sets: `discovery.ranking.algo_weight_sets`
|
||||||
|
- Safe rollout: deterministic traffic split by `algo_version` with config gates (`g10`, `g50`, `g100`)
|
||||||
|
- Emergency rollback: `DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1`
|
||||||
|
|
||||||
|
Offline evaluator and A/B helper:
|
||||||
|
|
||||||
|
- Evaluate objective across one/all algos:
|
||||||
|
- `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD`
|
||||||
|
- Optional: `--algo=clip-cosine-v1`
|
||||||
|
- Baseline vs candidate comparison:
|
||||||
|
- `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD`
|
||||||
|
|
||||||
|
Objective score uses `feed_daily_metrics` and configurable objective weights in `discovery.evaluation.objective_weights`.
|
||||||
|
|
||||||
|
Temporary production policy: set `DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true` to keep `save_rate` visible but excluded from objective score until save-event ingestion is verified.
|
||||||
|
|
||||||
|
Operational runbook: `docs/feed-rollout-runbook.md`.
|
||||||
|
|
||||||
|
## Operations / Runbooks
|
||||||
|
|
||||||
|
- Upload UI v2 rollout, post-deploy monitoring, and rollback: `docs/ui/upload-v2-rollout-runbook.md`
|
||||||
|
- Feed rollout and rollback: `docs/feed-rollout-runbook.md`
|
||||||
|
- Registration anti-spam and email quota protection: `docs/registration-antispam.md`
|
||||||
|
|
||||||
|
No automatic tuning is enabled in this phase.
|
||||||
|
|
||||||
|
Skinbase uses asynchronous AI tagging via `AutoTagArtworkJob`.
|
||||||
|
The job calls external vision services (CLIP and optional YOLO), normalizes tags, and attaches them through `TagService` as AI tags with confidence values.
|
||||||
|
|
||||||
|
### Critical Safety Rule
|
||||||
|
|
||||||
|
⚠️ **Publish must never depend on vision services.**
|
||||||
|
|
||||||
|
- Upload/publish flow dispatches AI tagging to queue after publish work.
|
||||||
|
- Vision failures, timeouts, or service outages must not block artwork publish.
|
||||||
|
- If AI tagging fails, artwork remains published and can be tagged later (retry/manual/batch).
|
||||||
|
|
||||||
|
### Environment Variables (Vision)
|
||||||
|
|
||||||
|
Set these in `.env` (all are optional; defaults are in `config/vision.php`):
|
||||||
|
|
||||||
|
#### Global
|
||||||
|
|
||||||
|
- `VISION_ENABLED` (default: `true`)
|
||||||
|
- Master switch for all AI auto-tagging.
|
||||||
|
- `VISION_QUEUE` (default: `default`)
|
||||||
|
- Queue name used by `AutoTagArtworkJob`.
|
||||||
|
- `VISION_IMAGE_VARIANT` (default: `md`)
|
||||||
|
- Derivative variant sent to vision services (e.g. `md`, `lg`).
|
||||||
|
|
||||||
|
#### CLIP
|
||||||
|
|
||||||
|
- `CLIP_BASE_URL` (default: empty)
|
||||||
|
- Base URL for CLIP service (example: `https://clip.internal`).
|
||||||
|
- If empty, CLIP call is skipped.
|
||||||
|
- `CLIP_ANALYZE_ENDPOINT` (default: `/analyze`)
|
||||||
|
- Path appended to `CLIP_BASE_URL`.
|
||||||
|
- `CLIP_TIMEOUT_SECONDS` (default: `8`)
|
||||||
|
- Request timeout for CLIP calls.
|
||||||
|
- `CLIP_CONNECT_TIMEOUT_SECONDS` (default: `2`)
|
||||||
|
- Connection timeout for CLIP calls.
|
||||||
|
- `CLIP_HTTP_RETRIES` (default: `1`)
|
||||||
|
- HTTP retry attempts for CLIP requests.
|
||||||
|
- `CLIP_HTTP_RETRY_DELAY_MS` (default: `200`)
|
||||||
|
- Delay between CLIP retries.
|
||||||
|
|
||||||
|
#### YOLO (optional)
|
||||||
|
|
||||||
|
- `YOLO_ENABLED` (default: `true`)
|
||||||
|
- Enables YOLO integration.
|
||||||
|
- `YOLO_BASE_URL` (default: empty)
|
||||||
|
- Base URL for YOLO service. If empty, YOLO call is skipped.
|
||||||
|
- `YOLO_ANALYZE_ENDPOINT` (default: `/analyze`)
|
||||||
|
- Path appended to `YOLO_BASE_URL`.
|
||||||
|
- `YOLO_TIMEOUT_SECONDS` (default: `8`)
|
||||||
|
- Request timeout for YOLO calls.
|
||||||
|
- `YOLO_CONNECT_TIMEOUT_SECONDS` (default: `2`)
|
||||||
|
- Connection timeout for YOLO calls.
|
||||||
|
- `YOLO_HTTP_RETRIES` (default: `1`)
|
||||||
|
- HTTP retry attempts for YOLO requests.
|
||||||
|
- `YOLO_HTTP_RETRY_DELAY_MS` (default: `200`)
|
||||||
|
- Delay between YOLO retries.
|
||||||
|
- `YOLO_PHOTOGRAPHY_ONLY` (default: `true`)
|
||||||
|
- When `true`, YOLO is called only for artworks in photography content type.
|
||||||
|
|
||||||
|
### Expected CLIP Response Format
|
||||||
|
|
||||||
|
CLIP `/analyze` should return tags as either a direct list or under `tags` / `data`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "tag": "cyberpunk", "confidence": 0.42 },
|
||||||
|
{ "tag": "city", "confidence": 0.31 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also accepted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": [
|
||||||
|
{ "tag": "cyberpunk", "confidence": 0.42 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "tag": "cyberpunk", "confidence": 0.42 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected YOLO Response Format
|
||||||
|
|
||||||
|
YOLO may return the same tag list format as CLIP, or object detections:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objects": [
|
||||||
|
{ "label": "person", "confidence": 0.91 },
|
||||||
|
{ "label": "camera", "confidence": 0.67 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`label` values are converted to tags, confidence is preserved when present.
|
||||||
|
|
||||||
|
### AutoTagArtworkJob Behavior
|
||||||
|
|
||||||
|
- Calls CLIP `/analyze` when `VISION_ENABLED=true` and `CLIP_BASE_URL` is set.
|
||||||
|
- Optionally calls YOLO based on `YOLO_ENABLED` and `YOLO_PHOTOGRAPHY_ONLY`.
|
||||||
|
- Merges CLIP + YOLO tags and keeps highest confidence for duplicates.
|
||||||
|
- Normalizes tags before attach (lowercase, cleanup, slug-safe format).
|
||||||
|
- Uses `TagService::attachAiTags()` to store pivot data:
|
||||||
|
- `source = ai`
|
||||||
|
- `confidence = <float|null>`
|
||||||
|
- Runs with queue retry + timeout safety (`tries`, `backoff`, `timeout`).
|
||||||
|
- Logs failures with reference/context for troubleshooting.
|
||||||
|
- On non-retriable response scenarios (e.g. 4xx), job exits safely without blocking publish.
|
||||||
|
|
||||||
|
### Queue / Worker Requirements (`VISION_QUEUE`)
|
||||||
|
|
||||||
|
- Ensure a worker is running for the configured queue.
|
||||||
|
- Example worker command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan queue:work --queue=default
|
||||||
|
```
|
||||||
|
|
||||||
|
- If `VISION_QUEUE=vision`, run worker for that queue:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan queue:work --queue=vision
|
||||||
|
```
|
||||||
|
|
||||||
|
- In production, use Supervisor/systemd/Horizon to keep workers alive.
|
||||||
|
- Without an active worker, auto-tagging jobs remain queued and will not execute.
|
||||||
|
|
||||||
|
### Local vs Production Notes
|
||||||
|
|
||||||
|
#### Local development
|
||||||
|
|
||||||
|
- For fully offline local work, set `VISION_ENABLED=false`.
|
||||||
|
- Or set only `CLIP_BASE_URL`/`YOLO_BASE_URL` you can reach locally.
|
||||||
|
- Prefer short timeouts to avoid slow dev feedback loops.
|
||||||
|
|
||||||
|
#### Production
|
||||||
|
|
||||||
|
- Use internal/private service endpoints for CLIP/YOLO when possible.
|
||||||
|
- Keep conservative timeouts and low retry counts to prevent queue congestion.
|
||||||
|
- Monitor failed jobs and logs for vision service reliability.
|
||||||
|
- Scale queue workers based on upload volume and service latency.
|
||||||
|
|
||||||
|
### Verify Setup (Health + Test Call)
|
||||||
|
|
||||||
|
After configuring env vars and restarting workers, verify in this order:
|
||||||
|
|
||||||
|
Quick helper (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File ./scripts/vision-smoke.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional flags:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File ./scripts/vision-smoke.ps1 -EnvFile ".env" -SampleImageUrl "https://files.skinbase.org/img/aa/bb/cc/md.webp"
|
||||||
|
pwsh -File ./scripts/vision-smoke.ps1 -SkipAnalyze
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Confirm queue worker is consuming `VISION_QUEUE`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan queue:work --queue=default
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Check CLIP/YOLO health endpoints (replace host/port as needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS "$CLIP_BASE_URL/health"
|
||||||
|
curl -fsS "$YOLO_BASE_URL/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Make a direct analyze test call (CLIP example):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"image_url":"https://files.skinbase.org/img/aa/bb/cc/md.webp"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Trigger an upload/publish and confirm:
|
||||||
|
|
||||||
|
- Publish response succeeds even if CLIP/YOLO is down.
|
||||||
|
- `AutoTagArtworkJob` is queued/executed asynchronously.
|
||||||
|
- AI tags appear on the artwork when services are healthy.
|
||||||
|
- Failures are logged, but publish is unaffected.
|
||||||
|
|
||||||
|
## Queue workers
|
||||||
|
|
||||||
|
The contact form mails are queued. To process them you need a worker. Locally you can run a foreground worker:
|
||||||
|
|
||||||
|
```
|
||||||
|
php artisan queue:work --sleep=3 --tries=3
|
||||||
|
```
|
||||||
|
|
||||||
|
For production we provide example configs under `deploy/`:
|
||||||
|
|
||||||
|
- `deploy/supervisor/skinbase-queue.conf` — Supervisor config
|
||||||
|
- `deploy/systemd/skinbase-queue.service` — systemd unit file
|
||||||
|
|
||||||
|
See `docs/QUEUE.md` for full setup steps and commands.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
|
|||||||
8
TODO.md
Normal file
8
TODO.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# TODO SKINBASE NOVA
|
||||||
|
|
||||||
|
## FORUM
|
||||||
|
|
||||||
|
- [ ] we need to add in a main search (toolbar) and a search in the forum (search bar in the forum page)
|
||||||
|
|
||||||
|
## ARTWORKS
|
||||||
|
- [ ] http://skinbase26.test/art/69601/testna-slika => we shouldnt display follow for yourself
|
||||||
@@ -6,11 +6,11 @@ class Banner
|
|||||||
{
|
{
|
||||||
public static function ShowResponsiveAd()
|
public static function ShowResponsiveAd()
|
||||||
{
|
{
|
||||||
echo '<div class="responsive_ad">';
|
#echo '<div class="responsive_ad">';
|
||||||
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
|
#echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
|
||||||
echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
|
#echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
|
||||||
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
|
#echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
|
||||||
echo '</div>';
|
#echo '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function ShowBanner300x250()
|
public static function ShowBanner300x250()
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ class Chat
|
|||||||
|
|
||||||
echo '<div class="row well">';
|
echo '<div class="row well">';
|
||||||
if (!empty($_SESSION['web_login']['status'])) {
|
if (!empty($_SESSION['web_login']['status'])) {
|
||||||
echo '<form action="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '" method="post">';
|
echo '<form action="' . htmlspecialchars(route('community.chat'), ENT_QUOTES, 'UTF-8') . '" method="post">';
|
||||||
|
echo csrf_field();
|
||||||
echo '<div class="col-sm-10">';
|
echo '<div class="col-sm-10">';
|
||||||
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
|
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
|
|||||||
106
app/Console/Commands/AggregateFeedAnalyticsCommand.php
Normal file
106
app/Console/Commands/AggregateFeedAnalyticsCommand.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AggregateFeedAnalyticsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:aggregate-feed {--date= : Date (Y-m-d), defaults to yesterday}';
|
||||||
|
|
||||||
|
protected $description = 'Aggregate feed analytics into daily metrics by algo version and source';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$date = $this->option('date')
|
||||||
|
? (string) $this->option('date')
|
||||||
|
: now()->subDay()->toDateString();
|
||||||
|
|
||||||
|
$rows = DB::table('feed_events')
|
||||||
|
->selectRaw('algo_version, source')
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds IS NOT NULL AND dwell_seconds < 5 THEN 1 ELSE 0 END) AS dwell_0_5")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 5 AND dwell_seconds < 30 THEN 1 ELSE 0 END) AS dwell_5_30")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 30 AND dwell_seconds < 120 THEN 1 ELSE 0 END) AS dwell_30_120")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 120 THEN 1 ELSE 0 END) AS dwell_120_plus")
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->groupBy('algo_version', 'source')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$algoVersion = (string) $row->algo_version;
|
||||||
|
$source = (string) $row->source;
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
|
||||||
|
$saves = $this->countSavesForGroup($date, $algoVersion, $source);
|
||||||
|
|
||||||
|
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||||
|
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
|
||||||
|
|
||||||
|
DB::table('feed_daily_metrics')->updateOrInsert(
|
||||||
|
[
|
||||||
|
'metric_date' => $date,
|
||||||
|
'algo_version' => $algoVersion,
|
||||||
|
'source' => $source,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'saves' => $saves,
|
||||||
|
'ctr' => $ctr,
|
||||||
|
'save_rate' => $saveRate,
|
||||||
|
'dwell_0_5' => (int) ($row->dwell_0_5 ?? 0),
|
||||||
|
'dwell_5_30' => (int) ($row->dwell_5_30 ?? 0),
|
||||||
|
'dwell_30_120' => (int) ($row->dwell_30_120 ?? 0),
|
||||||
|
'dwell_120_plus' => (int) ($row->dwell_120_plus ?? 0),
|
||||||
|
'updated_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Aggregated feed analytics for {$date}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countSavesForGroup(string $date, string $algoVersion, string $source): int
|
||||||
|
{
|
||||||
|
/** @var Collection<int, object{user_id:int,artwork_id:int}> $clickedPairs */
|
||||||
|
$clickedPairs = DB::table('feed_events')
|
||||||
|
->select('user_id', 'artwork_id')
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->where('event_type', 'feed_click')
|
||||||
|
->where('algo_version', $algoVersion)
|
||||||
|
->where('source', $source)
|
||||||
|
->groupBy('user_id', 'artwork_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($clickedPairs->isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$saves = 0;
|
||||||
|
foreach ($clickedPairs as $pair) {
|
||||||
|
$hasSave = DB::table('user_discovery_events')
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->where('user_id', (int) $pair->user_id)
|
||||||
|
->where('artwork_id', (int) $pair->artwork_id)
|
||||||
|
->where('algo_version', $algoVersion)
|
||||||
|
->whereIn('event_type', ['favorite', 'download'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasSave) {
|
||||||
|
$saves++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $saves;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AggregateSimilarArtworkAnalyticsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:aggregate-similar-artworks {--date= : Date (Y-m-d), defaults to yesterday}';
|
||||||
|
|
||||||
|
protected $description = 'Aggregate similar artwork analytics into daily counts by algo version';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$date = $this->option('date')
|
||||||
|
? (string) $this->option('date')
|
||||||
|
: now()->subDay()->toDateString();
|
||||||
|
|
||||||
|
$rows = DB::table('similar_artwork_events')
|
||||||
|
->selectRaw('algo_version')
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->groupBy('algo_version')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||||
|
|
||||||
|
DB::table('similar_artwork_daily_metrics')->updateOrInsert(
|
||||||
|
[
|
||||||
|
'metric_date' => $date,
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'ctr' => $ctr,
|
||||||
|
'updated_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Aggregated similar artwork analytics for {$date}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AggregateTagInteractionAnalyticsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:aggregate-tag-interactions {--date= : Date (Y-m-d), defaults to yesterday}';
|
||||||
|
|
||||||
|
protected $description = 'Aggregate tag interaction analytics into daily metrics by surface, tag, source tag, and query';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$date = $this->option('date')
|
||||||
|
? (string) $this->option('date')
|
||||||
|
: now()->subDay()->toDateString();
|
||||||
|
|
||||||
|
$normalizedTag = "COALESCE(tag_slug, '')";
|
||||||
|
$normalizedSourceTag = "COALESCE(source_tag_slug, '')";
|
||||||
|
$normalizedQuery = "LOWER(TRIM(COALESCE(query, '')))";
|
||||||
|
|
||||||
|
$rows = DB::table('tag_interaction_events')
|
||||||
|
->selectRaw('surface')
|
||||||
|
->selectRaw("{$normalizedTag} AS tag_slug")
|
||||||
|
->selectRaw("{$normalizedSourceTag} AS source_tag_slug")
|
||||||
|
->selectRaw("{$normalizedQuery} AS query")
|
||||||
|
->selectRaw('COUNT(*) AS clicks')
|
||||||
|
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
|
||||||
|
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
|
||||||
|
->selectRaw('AVG(position) AS avg_position')
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->where('event_type', 'click')
|
||||||
|
->groupBy('surface', DB::raw($normalizedTag), DB::raw($normalizedSourceTag), DB::raw($normalizedQuery))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($date, $rows): void {
|
||||||
|
DB::table('tag_interaction_daily_metrics')
|
||||||
|
->where('metric_date', $date)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$payload = $rows->map(static function ($row) use ($date): array {
|
||||||
|
return [
|
||||||
|
'metric_date' => $date,
|
||||||
|
'surface' => (string) $row->surface,
|
||||||
|
'tag_slug' => trim((string) ($row->tag_slug ?? '')),
|
||||||
|
'source_tag_slug' => trim((string) ($row->source_tag_slug ?? '')),
|
||||||
|
'query' => trim((string) ($row->query ?? '')),
|
||||||
|
'clicks' => (int) ($row->clicks ?? 0),
|
||||||
|
'unique_users' => (int) ($row->unique_users ?? 0),
|
||||||
|
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||||
|
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
})->all();
|
||||||
|
|
||||||
|
foreach (array_chunk($payload, 500) as $chunk) {
|
||||||
|
if ($chunk !== []) {
|
||||||
|
DB::table('tag_interaction_daily_metrics')->insert($chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("Aggregated tag interaction analytics for {$date}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
488
app/Console/Commands/AiTagArtworksCommand.php
Normal file
488
app/Console/Commands/AiTagArtworksCommand.php
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Services\TagService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate AI tags for artworks using a local LM Studio vision model.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan artworks:ai-tag
|
||||||
|
* php artisan artworks:ai-tag --after-id=1000 --chunk=20 --dry-run
|
||||||
|
* php artisan artworks:ai-tag --limit=100 --skip-tagged
|
||||||
|
* php artisan artworks:ai-tag --artwork-id=242 # process a single artwork by ID
|
||||||
|
* php artisan artworks:ai-tag --artwork-id=242 --dump-curl # print equivalent curl command (no API call made)
|
||||||
|
* php artisan artworks:ai-tag --artwork-id=242 --debug # print CDN URL, file size, magic bytes and data-URI prefix
|
||||||
|
* php artisan artworks:ai-tag --url=http://192.168.1.5:8200 --model=google/gemma-3-4b
|
||||||
|
*/
|
||||||
|
final class AiTagArtworksCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:ai-tag
|
||||||
|
{--artwork-id= : Process only this single artwork ID (bypasses public/approved scope)}
|
||||||
|
{--after-id=0 : Skip artworks with ID ≤ this value (useful for resuming)}
|
||||||
|
{--limit= : Stop after processing this many artworks}
|
||||||
|
{--chunk=50 : DB chunk size}
|
||||||
|
{--dry-run : Print tags but do not persist them}
|
||||||
|
{--skip-tagged : Skip artworks that already have at least one AI tag}
|
||||||
|
{--url-only : Send CDN URL instead of base64 (only works if LM Studio can reach the CDN)}
|
||||||
|
{--dump-curl : Print the equivalent curl command for the API call and skip the actual request}
|
||||||
|
{--debug : Print CDN URL, file size, magic bytes and data-URI prefix for each image}
|
||||||
|
{--url= : LM Studio base URL (overrides config/env)}
|
||||||
|
{--model= : Model identifier (overrides config/env)}
|
||||||
|
{--clear-ai-tags : Delete existing AI tags for each artwork before re-tagging}
|
||||||
|
';
|
||||||
|
|
||||||
|
protected $description = 'Generate tags for artworks via a local LM Studio vision model';
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Prompt
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||||
|
You are a precise visual-art tagging engine for an artwork gallery.
|
||||||
|
|
||||||
|
Your task is to analyse an artwork image and generate high-quality search tags that are useful for discovery, filtering, and categorisation.
|
||||||
|
|
||||||
|
Prioritise tags that are:
|
||||||
|
- visually evident in the image
|
||||||
|
- concise and specific
|
||||||
|
- useful for gallery search
|
||||||
|
|
||||||
|
Prefer concrete visual concepts over vague opinions.
|
||||||
|
Do not invent details that are not clearly visible.
|
||||||
|
Do not include artist names, brands, watermarks, or assumptions about intent unless directly visible.
|
||||||
|
|
||||||
|
Return tags that describe:
|
||||||
|
- subject or scene
|
||||||
|
- art style or genre
|
||||||
|
- mood or atmosphere
|
||||||
|
- colour palette
|
||||||
|
- technique or medium if visually apparent
|
||||||
|
- composition or notable visual elements if relevant
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
- generic filler tags like "beautiful", "nice", "art", "image"
|
||||||
|
- duplicate or near-duplicate tags
|
||||||
|
- full sentences
|
||||||
|
- overly broad tags when a more specific one is visible
|
||||||
|
|
||||||
|
Output must be deterministic, compact, and consistent.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
private const USER_PROMPT = <<<'PROMPT'
|
||||||
|
Analyse this artwork image and return a JSON array of relevant tags.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Return ONLY a valid JSON array of lowercase strings.
|
||||||
|
- No markdown, no explanation, no extra text.
|
||||||
|
- Output between 8 and 14 tags.
|
||||||
|
- Each tag must be 1 to 3 words.
|
||||||
|
- Use only letters, numbers, spaces, and hyphens.
|
||||||
|
- Do not end tags with punctuation.
|
||||||
|
- Do not include duplicate or near-duplicate tags.
|
||||||
|
- Order tags from most important to least important.
|
||||||
|
|
||||||
|
Focus on tags from these groups when visible:
|
||||||
|
1. main subject or scene
|
||||||
|
2. style or genre
|
||||||
|
3. mood or atmosphere
|
||||||
|
4. dominant colours
|
||||||
|
5. medium or technique
|
||||||
|
6. notable visual elements or composition
|
||||||
|
|
||||||
|
Tagging guidelines:
|
||||||
|
- Prefer specific tags over generic ones.
|
||||||
|
- Use searchable gallery-style tags.
|
||||||
|
- Include only what is clearly visible or strongly implied by the image.
|
||||||
|
- If the artwork is abstract, prioritise style, colour, mood, and composition.
|
||||||
|
- If the artwork is representational, prioritise subject, setting, style, and mood.
|
||||||
|
- If a detail is uncertain, leave it out.
|
||||||
|
|
||||||
|
Good output example:
|
||||||
|
["fantasy portrait","digital painting","female warrior","blue tones","dramatic lighting","glowing eyes","cinematic mood","detailed armor"]
|
||||||
|
|
||||||
|
Bad output example:
|
||||||
|
["art","beautiful image","very cool fantasy woman","amazing colors","masterpiece"]
|
||||||
|
|
||||||
|
Now return only the JSON array.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function __construct(private readonly TagService $tagService)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
|
||||||
|
$afterId = max(0, (int) $this->option('after-id'));
|
||||||
|
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||||
|
$chunk = max(1, min((int) $this->option('chunk'), 200));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$skipTagged = (bool) $this->option('skip-tagged');
|
||||||
|
$dumpCurl = (bool) $this->option('dump-curl');
|
||||||
|
$verbose = (bool) $this->option('debug');
|
||||||
|
$useBase64 = !(bool) $this->option('url-only');
|
||||||
|
$clearAiTags = (bool) $this->option('clear-ai-tags');
|
||||||
|
|
||||||
|
$baseUrl = rtrim((string) ($this->option('url') ?: config('vision.lm_studio.base_url')), '/');
|
||||||
|
$model = (string) ($this->option('model') ?: config('vision.lm_studio.model'));
|
||||||
|
$maxTags = (int) config('vision.lm_studio.max_tags', 12);
|
||||||
|
|
||||||
|
$this->info("LM Studio : {$baseUrl}");
|
||||||
|
$this->info("Model : {$model}");
|
||||||
|
$this->info("Image mode : " . ($useBase64 ? 'base64 (default)' : 'CDN URL (--url-only)'));
|
||||||
|
$this->info("Dry run : " . ($dryRun ? 'YES' : 'no'));
|
||||||
|
$this->info("Clear AI : " . ($clearAiTags ? 'YES — existing AI tags deleted first' : 'no'));
|
||||||
|
if ($artworkId !== null) {
|
||||||
|
$this->info("Artwork ID : {$artworkId} (single-artwork mode)");
|
||||||
|
}
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
// Single-artwork mode: bypass public/approved scope so any artwork can be tested.
|
||||||
|
if ($artworkId !== null) {
|
||||||
|
$artwork = Artwork::withTrashed()->find($artworkId);
|
||||||
|
if ($artwork === null) {
|
||||||
|
$this->error("Artwork #{$artworkId} not found.");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
$limit = 1;
|
||||||
|
$query = Artwork::withTrashed()->where('id', $artworkId);
|
||||||
|
} else {
|
||||||
|
$query = Artwork::query()
|
||||||
|
->public()
|
||||||
|
->where('id', '>', $afterId)
|
||||||
|
->whereNotNull('hash')
|
||||||
|
->whereNotNull('thumb_ext')
|
||||||
|
->orderBy('id');
|
||||||
|
|
||||||
|
if ($skipTagged) {
|
||||||
|
// Exclude artworks that already have an AI-sourced tag in the pivot.
|
||||||
|
$query->whereDoesntHave('tags', fn ($q) => $q->where('artwork_tag.source', 'ai'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$tagged = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$errors = 0;
|
||||||
|
|
||||||
|
$query->chunkById($chunk, function ($artworks) use (
|
||||||
|
&$processed, &$tagged, &$skipped, &$errors,
|
||||||
|
$limit, $dryRun, $dumpCurl, $verbose, $useBase64, $baseUrl, $model, $maxTags, $clearAiTags,
|
||||||
|
) {
|
||||||
|
foreach ($artworks as $artwork) {
|
||||||
|
if ($limit !== null && $processed >= $limit) {
|
||||||
|
return false; // stop iteration
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
$imageUrl = $artwork->thumbUrl('md');
|
||||||
|
if ($imageUrl === null) {
|
||||||
|
$this->warn(" [#{$artwork->id}] No thumb URL — skip");
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" [#{$artwork->id}] {$artwork->title}");
|
||||||
|
|
||||||
|
// Remove AI tags first if requested.
|
||||||
|
if ($clearAiTags) {
|
||||||
|
$aiTagIds = DB::table('artwork_tag')
|
||||||
|
->where('artwork_id', $artwork->id)
|
||||||
|
->where('source', 'ai')
|
||||||
|
->pluck('tag_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($aiTagIds !== []) {
|
||||||
|
if (!$dryRun) {
|
||||||
|
$this->tagService->detachTags($artwork, $aiTagIds);
|
||||||
|
}
|
||||||
|
$this->line(' ✂ Cleared ' . count($aiTagIds) . ' existing AI tag(s)' . ($dryRun ? ' (dry-run)' : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verbose) {
|
||||||
|
$this->line(" CDN URL : {$imageUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tags = $this->fetchTags($baseUrl, $model, $imageUrl, $useBase64, $maxTags, $dumpCurl, $verbose);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error(" ✗ API error: " . $e->getMessage());
|
||||||
|
// Show first 120 chars of the response body for easier debugging.
|
||||||
|
if (str_contains($e->getMessage(), 'status code')) {
|
||||||
|
$this->line(" (use --dry-run to test without saving)");
|
||||||
|
}
|
||||||
|
Log::error('artworks:ai-tag API error', [
|
||||||
|
'artwork_id' => $artwork->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tags === []) {
|
||||||
|
$this->warn(" ✗ No tags returned");
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagList = implode(', ', $tags);
|
||||||
|
$this->line(" → {$tagList}");
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$aiTagPayload = array_map(fn (string $t) => ['tag' => $t, 'confidence' => null], $tags);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->tagService->attachAiTags($artwork, $aiTagPayload);
|
||||||
|
$tagged++;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error(" ✗ Save error: " . $e->getMessage());
|
||||||
|
Log::error('artworks:ai-tag save error', [
|
||||||
|
'artwork_id' => $artwork->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$errors++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$tagged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->line('');
|
||||||
|
$this->info("Done. processed={$processed} tagged={$tagged} skipped={$skipped} errors={$errors}");
|
||||||
|
|
||||||
|
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// LM Studio API call
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function fetchTags(
|
||||||
|
string $baseUrl,
|
||||||
|
string $model,
|
||||||
|
string $imageUrl,
|
||||||
|
bool $useBase64,
|
||||||
|
int $maxTags,
|
||||||
|
bool $dumpCurl = false,
|
||||||
|
bool $verbose = false,
|
||||||
|
): array {
|
||||||
|
$imageContent = $useBase64
|
||||||
|
? $this->buildBase64ImageContent($imageUrl, $verbose)
|
||||||
|
: ['type' => 'image_url', 'image_url' => ['url' => $imageUrl]];
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'model' => $model,
|
||||||
|
'temperature' => (float) config('vision.lm_studio.temperature', 0.3),
|
||||||
|
'max_tokens' => (int) config('vision.lm_studio.max_tokens', 300),
|
||||||
|
'messages' => [
|
||||||
|
[
|
||||||
|
'role' => 'system',
|
||||||
|
'content' => self::SYSTEM_PROMPT,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => [
|
||||||
|
$imageContent,
|
||||||
|
['type' => 'text', 'text' => self::USER_PROMPT],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$timeout = (int) config('vision.lm_studio.timeout', 60);
|
||||||
|
$connectTimeout = (int) config('vision.lm_studio.connect_timeout', 5);
|
||||||
|
$endpoint = "{$baseUrl}/v1/chat/completions";
|
||||||
|
|
||||||
|
// --dump-curl: write payload to a temp file and print the equivalent curl command.
|
||||||
|
if ($dumpCurl) {
|
||||||
|
$jsonPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
// Truncate any base64 data URIs in the printed output so the terminal stays readable.
|
||||||
|
$printable = preg_replace(
|
||||||
|
'/("data:[^;]+;base64,)([A-Za-z0-9+\/=]{60})[A-Za-z0-9+\/=]+(")/',
|
||||||
|
'$1$2...[base64 truncated]$3',
|
||||||
|
$jsonPayload,
|
||||||
|
) ?? $jsonPayload;
|
||||||
|
|
||||||
|
// Write the full (untruncated) payload to a temp file for use with curl --data.
|
||||||
|
$tmpJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_payload_' . uniqid() . '.json';
|
||||||
|
file_put_contents($tmpJson, $jsonPayload);
|
||||||
|
|
||||||
|
$this->line('');
|
||||||
|
$this->line('<fg=yellow>--- Payload (base64 truncated for display) ---</>');
|
||||||
|
$this->line($printable);
|
||||||
|
$this->line('');
|
||||||
|
$this->line('<fg=yellow>--- curl command (full payload in temp file) ---</>');
|
||||||
|
$this->line(
|
||||||
|
'curl -s -X POST ' . escapeshellarg($endpoint)
|
||||||
|
. ' -H ' . escapeshellarg('Content-Type: application/json')
|
||||||
|
. ' --data @' . escapeshellarg($tmpJson)
|
||||||
|
. ' | python -m json.tool'
|
||||||
|
);
|
||||||
|
$this->line('');
|
||||||
|
$this->info("Full JSON payload written to: {$tmpJson}");
|
||||||
|
|
||||||
|
// Return empty — no real API call is made.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::timeout($timeout)
|
||||||
|
->connectTimeout($connectTimeout)
|
||||||
|
->post($endpoint, $payload)
|
||||||
|
->throw();
|
||||||
|
|
||||||
|
$body = $response->json();
|
||||||
|
$content = $body['choices'][0]['message']['content'] ?? '';
|
||||||
|
|
||||||
|
return $this->parseTags((string) $content, $maxTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the image using the system curl binary (raw bytes, no encoding surprises),
|
||||||
|
* base64-encode from the local file, then delete it.
|
||||||
|
*
|
||||||
|
* Using curl directly is more reliable than the Laravel Http client here because it
|
||||||
|
* avoids gzip/deflate decoding issues, chunked-transfer quirks, and header parsing
|
||||||
|
* edge cases that could corrupt the image bytes before encoding.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @throws \RuntimeException if curl fails or the file is empty
|
||||||
|
*/
|
||||||
|
private function buildBase64ImageContent(string $imageUrl, bool $verbose = false): array
|
||||||
|
{
|
||||||
|
$ext = strtolower(pathinfo(parse_url($imageUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
|
||||||
|
$mime = match ($ext) {
|
||||||
|
'png' => 'image/png',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
default => 'image/jpeg',
|
||||||
|
};
|
||||||
|
|
||||||
|
$tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_' . uniqid() . '.' . ($ext ?: 'jpg');
|
||||||
|
|
||||||
|
try {
|
||||||
|
exec(
|
||||||
|
'curl -s -f -L --max-time 30 -o ' . escapeshellarg($tmpPath) . ' ' . escapeshellarg($imageUrl),
|
||||||
|
$output,
|
||||||
|
$exitCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($exitCode !== 0 || !file_exists($tmpPath) || filesize($tmpPath) === 0) {
|
||||||
|
throw new \RuntimeException("curl failed to download image (exit={$exitCode}, size=" . (file_exists($tmpPath) ? filesize($tmpPath) : 'N/A') . "): {$imageUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBytes = file_get_contents($tmpPath);
|
||||||
|
if ($rawBytes === false || $rawBytes === '') {
|
||||||
|
throw new \RuntimeException("file_get_contents returned empty after curl download: {$tmpPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// LM Studio does not support WebP. Convert to JPEG via GD if needed.
|
||||||
|
if ($mime === 'image/webp') {
|
||||||
|
$convertedPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_conv_' . uniqid() . '.jpg';
|
||||||
|
try {
|
||||||
|
if (!function_exists('imagecreatefromwebp')) {
|
||||||
|
throw new \RuntimeException('GD extension with WebP support is required to convert WebP images. Enable ext-gd with WebP support in php.ini.');
|
||||||
|
}
|
||||||
|
$img = imagecreatefromwebp($tmpPath);
|
||||||
|
if ($img === false) {
|
||||||
|
throw new \RuntimeException("GD failed to load WebP: {$tmpPath}");
|
||||||
|
}
|
||||||
|
imagejpeg($img, $convertedPath, 92);
|
||||||
|
imagedestroy($img);
|
||||||
|
$rawBytes = file_get_contents($convertedPath);
|
||||||
|
$mime = 'image/jpeg';
|
||||||
|
if ($verbose) {
|
||||||
|
$this->line(' Convert : WebP → JPEG (LM Studio does not accept WebP)');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
@unlink($convertedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verbose) {
|
||||||
|
$fileSize = filesize($tmpPath);
|
||||||
|
// Show first 8 bytes as hex to confirm it's a real image, not an HTML error page.
|
||||||
|
$magicHex = strtoupper(bin2hex(substr($rawBytes, 0, 8)));
|
||||||
|
$this->line(" File : {$tmpPath}");
|
||||||
|
$this->line(" Size : {$fileSize} bytes");
|
||||||
|
$this->line(" Magic : {$magicHex} (JPEG=FFD8FF, PNG=89504E47, WEBP=52494646)");
|
||||||
|
}
|
||||||
|
|
||||||
|
$base64 = base64_encode($rawBytes);
|
||||||
|
$dataUri = "data:{$mime};base64,{$base64}";
|
||||||
|
|
||||||
|
if ($verbose) {
|
||||||
|
$this->line(" MIME : {$mime}");
|
||||||
|
$this->line(" URI pfx : " . substr($dataUri, 0, 60) . '...');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
@unlink($tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['type' => 'image_url', 'image_url' => ['url' => $dataUri]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Response parsing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a JSON array from the model's response text.
|
||||||
|
*
|
||||||
|
* The model should return just the array, but may include surrounding text
|
||||||
|
* or markdown code fences, so we search for the first `[…]` block.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function parseTags(string $content, int $maxTags): array
|
||||||
|
{
|
||||||
|
$content = trim($content);
|
||||||
|
|
||||||
|
// Strip markdown code fences if present (```json … ```)
|
||||||
|
$content = preg_replace('/^```(?:json)?\s*/i', '', $content) ?? $content;
|
||||||
|
$content = preg_replace('/\s*```$/', '', $content) ?? $content;
|
||||||
|
|
||||||
|
// Extract the first JSON array from the text.
|
||||||
|
if (!preg_match('/(\[.*?\])/s', $content, $matches)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($matches[1], true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = [];
|
||||||
|
foreach ($decoded as $item) {
|
||||||
|
if (!is_string($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$clean = trim(strtolower((string) $item));
|
||||||
|
if ($clean !== '') {
|
||||||
|
$tags[] = $clean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect the configured max-tags ceiling.
|
||||||
|
return array_slice(array_unique($tags), 0, $maxTags);
|
||||||
|
}
|
||||||
|
}
|
||||||
519
app/Console/Commands/AuditMigrationSchemaCommand.php
Normal file
519
app/Console/Commands/AuditMigrationSchemaCommand.php
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Symfony\Component\Finder\Finder;
|
||||||
|
|
||||||
|
class AuditMigrationSchemaCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'schema:audit-migrations
|
||||||
|
{--all-files : Audit all discovered migration files, not only migrations marked as ran}
|
||||||
|
{--json : Output the report as JSON}
|
||||||
|
{--base-path=* : Additional base paths to scan for migrations, relative to project root}';
|
||||||
|
|
||||||
|
protected $description = 'Compare the live database schema against executed migration files and report missing tables or columns';
|
||||||
|
|
||||||
|
private const NO_ARG_COLUMN_METHODS = [
|
||||||
|
'id' => ['id'],
|
||||||
|
'timestamps' => ['created_at', 'updated_at'],
|
||||||
|
'timestampsTz' => ['created_at', 'updated_at'],
|
||||||
|
'softDeletes' => ['deleted_at'],
|
||||||
|
'softDeletesTz' => ['deleted_at'],
|
||||||
|
'rememberToken' => ['remember_token'],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const NON_COLUMN_METHODS = [
|
||||||
|
'index',
|
||||||
|
'unique',
|
||||||
|
'primary',
|
||||||
|
'foreign',
|
||||||
|
'foreignIdFor',
|
||||||
|
'dropColumn',
|
||||||
|
'dropColumns',
|
||||||
|
'dropIndex',
|
||||||
|
'dropUnique',
|
||||||
|
'dropPrimary',
|
||||||
|
'dropForeign',
|
||||||
|
'dropConstrainedForeignId',
|
||||||
|
'renameColumn',
|
||||||
|
'renameIndex',
|
||||||
|
'constrained',
|
||||||
|
'cascadeOnDelete',
|
||||||
|
'restrictOnDelete',
|
||||||
|
'nullOnDelete',
|
||||||
|
'cascadeOnUpdate',
|
||||||
|
'restrictOnUpdate',
|
||||||
|
'nullOnUpdate',
|
||||||
|
'after',
|
||||||
|
'nullable',
|
||||||
|
'default',
|
||||||
|
'useCurrent',
|
||||||
|
'useCurrentOnUpdate',
|
||||||
|
'comment',
|
||||||
|
'charset',
|
||||||
|
'collation',
|
||||||
|
'storedAs',
|
||||||
|
'virtualAs',
|
||||||
|
'generatedAs',
|
||||||
|
'always',
|
||||||
|
'invisible',
|
||||||
|
'first',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$migrationFiles = $this->discoverMigrationFiles();
|
||||||
|
$ranMigrations = collect(DB::table('migrations')->pluck('migration')->all())
|
||||||
|
->mapWithKeys(fn (string $migration): array => [$migration => true])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$expected = [];
|
||||||
|
$parsedFiles = 0;
|
||||||
|
|
||||||
|
foreach ($migrationFiles as $migrationName => $path) {
|
||||||
|
if (! $this->option('all-files') && ! isset($ranMigrations[$migrationName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsedFiles++;
|
||||||
|
$operations = $this->parseMigrationFile($path);
|
||||||
|
|
||||||
|
foreach ($operations as $operation) {
|
||||||
|
$table = $operation['table'];
|
||||||
|
|
||||||
|
if ($operation['type'] === 'create-table' && isset($expected[$table])) {
|
||||||
|
$expected[$table]['sources'][$migrationName] = true;
|
||||||
|
|
||||||
|
if (Schema::hasTable($table)) {
|
||||||
|
$actualColumns = array_fill_keys(
|
||||||
|
array_map('strtolower', Schema::getColumnListing($table)),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$existingColumns = array_fill_keys(array_keys($expected[$table]['columns']), true);
|
||||||
|
$replacementColumns = [];
|
||||||
|
|
||||||
|
foreach ($operation['add'] as $column) {
|
||||||
|
if (! isset($existingColumns[$column]) && isset($actualColumns[$column])) {
|
||||||
|
$replacementColumns[$column] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($replacementColumns !== []) {
|
||||||
|
foreach ($replacementColumns as $column => $_) {
|
||||||
|
$expected[$table]['columns'][$column] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($expected[$table]['columns']) as $column) {
|
||||||
|
if (! isset($actualColumns[$column]) && ! isset($replacementColumns[$column])) {
|
||||||
|
unset($expected[$table]['columns'][$column]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operation['type'] === 'alter-table' && ! isset($expected[$table]) && ! Schema::hasTable($table)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected[$table] ??= [
|
||||||
|
'columns' => [],
|
||||||
|
'sources' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$expected[$table]['sources'][$migrationName] = true;
|
||||||
|
|
||||||
|
if ($operation['type'] === 'drop-table') {
|
||||||
|
unset($expected[$table]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($operation['add'] as $column) {
|
||||||
|
$expected[$table]['columns'][$column] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($operation['drop'] as $column) {
|
||||||
|
unset($expected[$table]['columns'][$column]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($expected);
|
||||||
|
|
||||||
|
$report = [
|
||||||
|
'parsed_files' => $parsedFiles,
|
||||||
|
'expected_tables' => count($expected),
|
||||||
|
'missing_tables' => [],
|
||||||
|
'missing_columns' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($expected as $table => $spec) {
|
||||||
|
$sources = array_keys($spec['sources']);
|
||||||
|
sort($sources);
|
||||||
|
|
||||||
|
if (! Schema::hasTable($table)) {
|
||||||
|
$report['missing_tables'][] = [
|
||||||
|
'table' => $table,
|
||||||
|
'sources' => $sources,
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actualColumns = array_map('strtolower', Schema::getColumnListing($table));
|
||||||
|
$expectedColumns = array_keys($spec['columns']);
|
||||||
|
sort($expectedColumns);
|
||||||
|
|
||||||
|
$missing = array_values(array_diff($expectedColumns, $actualColumns));
|
||||||
|
if ($missing !== []) {
|
||||||
|
$report['missing_columns'][] = [
|
||||||
|
'table' => $table,
|
||||||
|
'columns' => $missing,
|
||||||
|
'sources' => $sources,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bool) $this->option('json')) {
|
||||||
|
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
} else {
|
||||||
|
$this->renderReport($report);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($report['missing_tables'] === [] && $report['missing_columns'] === [])
|
||||||
|
? self::SUCCESS
|
||||||
|
: self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function discoverMigrationFiles(): array
|
||||||
|
{
|
||||||
|
$paths = [
|
||||||
|
database_path('migrations'),
|
||||||
|
base_path('packages/klevze'),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ((array) $this->option('base-path') as $relativePath) {
|
||||||
|
$resolved = base_path((string) $relativePath);
|
||||||
|
if (is_dir($resolved)) {
|
||||||
|
$paths[] = $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$finder = new Finder();
|
||||||
|
$finder->files()->name('*.php');
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$finder->in($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
foreach ($finder as $file) {
|
||||||
|
$realPath = $file->getRealPath();
|
||||||
|
if (! $realPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = str_replace('\\', '/', $realPath);
|
||||||
|
if (! str_contains($normalized, '/database/migrations/') && ! str_contains($normalized, '/Migrations/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[pathinfo($realPath, PATHINFO_FILENAME)] = $realPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($files);
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{type:string, table:string, add:array<int,string>, drop:array<int,string>}>
|
||||||
|
*/
|
||||||
|
private function parseMigrationFile(string $path): array
|
||||||
|
{
|
||||||
|
$content = File::get($path);
|
||||||
|
$upBody = $this->extractMethodBody($content, 'up');
|
||||||
|
if ($upBody === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$operations = [];
|
||||||
|
|
||||||
|
foreach ($this->extractSchemaClosures($upBody) as $closure) {
|
||||||
|
$operations[] = [
|
||||||
|
'type' => $closure['operation'],
|
||||||
|
'table' => $closure['table'],
|
||||||
|
'add' => $this->extractAddedColumns($closure['body']),
|
||||||
|
'drop' => $this->extractDroppedColumns($closure['body']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match_all("/Schema::drop(?:IfExists)?\(\s*['\"]([^'\"]+)['\"]\s*\)/", $upBody, $matches)) {
|
||||||
|
foreach ($matches[1] as $table) {
|
||||||
|
$operations[] = [
|
||||||
|
'type' => 'drop-table',
|
||||||
|
'table' => strtolower((string) $table),
|
||||||
|
'add' => [],
|
||||||
|
'drop' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->extractRawAlterTableChanges($upBody) as $change) {
|
||||||
|
$operations[] = [
|
||||||
|
'type' => 'alter-table',
|
||||||
|
'table' => $change['table'],
|
||||||
|
'add' => [$change['new_column']],
|
||||||
|
'drop' => [$change['old_column']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{table:string, old_column:string, new_column:string}>
|
||||||
|
*/
|
||||||
|
private function extractRawAlterTableChanges(string $upBody): array
|
||||||
|
{
|
||||||
|
$changes = [];
|
||||||
|
|
||||||
|
if (preg_match_all(
|
||||||
|
'/ALTER\s+TABLE\s+[`"]?([^`"\s]+)[`"]?\s+CHANGE(?:\s+COLUMN)?\s+[`"]?([^`"\s]+)[`"]?\s+[`"]?([^`"\s]+)[`"]?/i',
|
||||||
|
$upBody,
|
||||||
|
$matches,
|
||||||
|
PREG_SET_ORDER
|
||||||
|
)) {
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$oldColumn = strtolower((string) $match[2]);
|
||||||
|
$newColumn = strtolower((string) $match[3]);
|
||||||
|
|
||||||
|
if ($oldColumn === $newColumn) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[] = [
|
||||||
|
'table' => strtolower((string) $match[1]),
|
||||||
|
'old_column' => $oldColumn,
|
||||||
|
'new_column' => $newColumn,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractMethodBody(string $content, string $method): ?string
|
||||||
|
{
|
||||||
|
if (! preg_match('/function\s+' . preg_quote($method, '/') . '\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/m', $content, $match, PREG_OFFSET_CAPTURE)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $match[0][1] + strlen($match[0][0]) - 1;
|
||||||
|
$end = $this->findMatchingBrace($content, $start);
|
||||||
|
|
||||||
|
if ($end === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($content, $start + 1, $end - $start - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findMatchingBrace(string $content, int $openingBracePos): ?int
|
||||||
|
{
|
||||||
|
$length = strlen($content);
|
||||||
|
$depth = 0;
|
||||||
|
$inSingle = false;
|
||||||
|
$inDouble = false;
|
||||||
|
|
||||||
|
for ($index = $openingBracePos; $index < $length; $index++) {
|
||||||
|
$char = $content[$index];
|
||||||
|
$prev = $index > 0 ? $content[$index - 1] : '';
|
||||||
|
|
||||||
|
if ($char === "'" && ! $inDouble && $prev !== '\\') {
|
||||||
|
$inSingle = ! $inSingle;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '"' && ! $inSingle && $prev !== '\\') {
|
||||||
|
$inDouble = ! $inDouble;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inSingle || $inDouble) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '{') {
|
||||||
|
$depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '}') {
|
||||||
|
$depth--;
|
||||||
|
if ($depth === 0) {
|
||||||
|
return $index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{operation:string, table:string, body:string}>
|
||||||
|
*/
|
||||||
|
private function extractSchemaClosures(string $upBody): array
|
||||||
|
{
|
||||||
|
preg_match_all('/Schema::(create|table)\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*function/s', $upBody, $matches, PREG_OFFSET_CAPTURE);
|
||||||
|
|
||||||
|
$closures = [];
|
||||||
|
foreach ($matches[0] as $index => $fullMatch) {
|
||||||
|
$offset = (int) $fullMatch[1];
|
||||||
|
$operation = strtolower((string) $matches[1][$index][0]) === 'create' ? 'create-table' : 'alter-table';
|
||||||
|
$table = strtolower((string) $matches[2][$index][0]);
|
||||||
|
|
||||||
|
$bracePos = strpos($upBody, '{', $offset);
|
||||||
|
if ($bracePos === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$closing = $this->findMatchingBrace($upBody, $bracePos);
|
||||||
|
if ($closing === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$closures[] = [
|
||||||
|
'operation' => $operation,
|
||||||
|
'table' => $table,
|
||||||
|
'body' => substr($upBody, $bracePos + 1, $closing - $bracePos - 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $closures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function extractAddedColumns(string $body): array
|
||||||
|
{
|
||||||
|
$columns = [];
|
||||||
|
|
||||||
|
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$method = (string) $match[1];
|
||||||
|
$column = strtolower((string) $match[2]);
|
||||||
|
|
||||||
|
if (in_array($method, self::NON_COLUMN_METHODS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns[$column] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*\)(?:[^;]*)?;/s', $body, $matches, PREG_SET_ORDER)) {
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$method = (string) $match[1];
|
||||||
|
foreach (self::NO_ARG_COLUMN_METHODS[$method] ?? [] as $column) {
|
||||||
|
$columns[$column] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match_all('/\$table->(nullableMorphs|morphs|uuidMorphs|nullableUuidMorphs|ulidMorphs|nullableUlidMorphs)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$prefix = strtolower((string) $match[2]);
|
||||||
|
$columns[$prefix . '_type'] = true;
|
||||||
|
$columns[$prefix . '_id'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($columns);
|
||||||
|
|
||||||
|
return array_keys($columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function extractDroppedColumns(string $body): array
|
||||||
|
{
|
||||||
|
$columns = [];
|
||||||
|
|
||||||
|
if (preg_match_all('/\$table->dropColumn\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches)) {
|
||||||
|
foreach ($matches[1] as $column) {
|
||||||
|
$columns[strtolower((string) $column)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match_all('/\$table->dropColumn\(\s*\[(.*?)\]\s*\);/s', $body, $matches)) {
|
||||||
|
foreach ($matches[1] as $arrayBody) {
|
||||||
|
if (preg_match_all('/[\'\"]([^\'\"]+)[\'\"]/', $arrayBody, $columnMatches)) {
|
||||||
|
foreach ($columnMatches[1] as $column) {
|
||||||
|
$columns[strtolower((string) $column)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match_all('/\$table->renameColumn\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$columns[strtolower((string) $match[1])] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($columns);
|
||||||
|
|
||||||
|
return array_keys($columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{parsed_files:int, expected_tables:int, missing_tables:array<int,array{table:string,sources:array<int,string>}>, missing_columns:array<int,array{table:string,columns:array<int,string>,sources:array<int,string>}>} $report
|
||||||
|
*/
|
||||||
|
private function renderReport(array $report): void
|
||||||
|
{
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Parsed %d migration file(s). Expected schema covers %d table(s).',
|
||||||
|
$report['parsed_files'],
|
||||||
|
$report['expected_tables']
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($report['missing_tables'] === [] && $report['missing_columns'] === []) {
|
||||||
|
$this->info('Schema audit passed. No missing tables or columns detected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($report['missing_tables'] !== []) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->error('Missing tables:');
|
||||||
|
foreach ($report['missing_tables'] as $item) {
|
||||||
|
$this->line(sprintf(' - %s', $item['table']));
|
||||||
|
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($report['missing_columns'] !== []) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->error('Missing columns:');
|
||||||
|
foreach ($report['missing_columns'] as $item) {
|
||||||
|
$this->line(sprintf(' - %s: %s', $item['table'], implode(', ', $item['columns'])));
|
||||||
|
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal file
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class AvatarsBulkUpdate extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'avatars:bulk-update
|
||||||
|
{path=./user_profiles_avatar.csv : CSV file path (user_id,avatar_hash)}
|
||||||
|
{--dry-run : Do not write to database}
|
||||||
|
';
|
||||||
|
|
||||||
|
protected $description = 'Bulk update user_profiles.avatar_hash from CSV (user_id,avatar_hash)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$path = $this->argument('path');
|
||||||
|
$dry = $this->option('dry-run');
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
$this->error("CSV file not found: {$path}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Reading CSV: ' . $path);
|
||||||
|
|
||||||
|
if (($handle = fopen($path, 'r')) === false) {
|
||||||
|
$this->error('Unable to open CSV file');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = 0;
|
||||||
|
$updates = 0;
|
||||||
|
|
||||||
|
while (($data = fgetcsv($handle)) !== false) {
|
||||||
|
$row++;
|
||||||
|
// Skip empty rows
|
||||||
|
if (count($data) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect at least two columns: user_id, avatar_hash
|
||||||
|
$userId = isset($data[0]) ? trim($data[0]) : null;
|
||||||
|
$hash = isset($data[1]) ? trim($data[1]) : null;
|
||||||
|
|
||||||
|
// If first row looks like a header, skip it
|
||||||
|
if ($row === 1 && (!is_numeric($userId) || $userId === 'user_id')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userId === '' || $hash === '') {
|
||||||
|
$this->line("[skip] row={$row} invalid data");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) $userId;
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$this->line("[dry] user={$userId} would set avatar_hash={$hash}");
|
||||||
|
$updates++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$affected = DB::table('user_profiles')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->update([ 'avatar_hash' => $hash, 'avatar_updated_at' => now() ]);
|
||||||
|
|
||||||
|
if ($affected) {
|
||||||
|
$this->line("[ok] user={$userId} avatar_hash updated");
|
||||||
|
$updates++;
|
||||||
|
} else {
|
||||||
|
$this->line("[noop] user={$userId} no row updated (missing profile?)");
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error("[error] user={$userId} {$e->getMessage()}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
$this->info("Done. Processed rows={$row} updates={$updates}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
415
app/Console/Commands/AvatarsMigrate.php
Normal file
415
app/Console/Commands/AvatarsMigrate.php
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserProfile;
|
||||||
|
use Intervention\Image\ImageManagerStatic as Image;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class AvatarsMigrate extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'avatars:migrate
|
||||||
|
{--dry-run : Do not write files or update database}
|
||||||
|
{--force : Overwrite existing migrated avatars}
|
||||||
|
{--remove-legacy : Remove legacy files after successful migration}
|
||||||
|
{--path=public/files/usericons : Legacy path to scan}
|
||||||
|
{--user-id= : Only migrate a single user by ID}
|
||||||
|
';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Migrate legacy avatars from public/files/usericons to storage/app/public/avatars and generate sizes (WebP)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed MIME types for source images.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $allowed = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target sizes to generate.
|
||||||
|
*
|
||||||
|
* @var int[]
|
||||||
|
*/
|
||||||
|
protected $sizes = [32, 40, 64, 80, 96, 128, 256, 512];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dry = $this->option('dry-run');
|
||||||
|
$force = $this->option('force');
|
||||||
|
$removeLegacy = $this->option('remove-legacy');
|
||||||
|
$legacyPath = base_path($this->option('path'));
|
||||||
|
$userId = $this->option('user-id') ? (int) $this->option('user-id') : null;
|
||||||
|
$verbose = $this->output->isVerbose();
|
||||||
|
|
||||||
|
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : ''));
|
||||||
|
|
||||||
|
// Detect processing backend: Intervention preferred, GD fallback
|
||||||
|
$useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic');
|
||||||
|
if ($useIntervention) {
|
||||||
|
Image::configure(['driver' => extension_loaded('imagick') ? 'imagick' : 'gd']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar = null;
|
||||||
|
|
||||||
|
$query = User::with('profile');
|
||||||
|
if ($userId) {
|
||||||
|
$query->where('id', $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention, $verbose) {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
/** @var UserProfile|null $profile */
|
||||||
|
$profile = $user->profile;
|
||||||
|
|
||||||
|
if (!$profile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already migrated unless --force
|
||||||
|
if (!$force && !empty($profile->avatar_hash)) {
|
||||||
|
$this->line("[skip] user={$user->id} already migrated");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $this->findLegacyFile($profile, $user->id, $legacyPath, 'legacy');
|
||||||
|
|
||||||
|
//dd($source);
|
||||||
|
if (!$source) {
|
||||||
|
if ($verbose) {
|
||||||
|
$this->line("[noop] user={$user->id} no legacy file found");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->line("[proc] user={$user->id} file={$source}");
|
||||||
|
|
||||||
|
if ($useIntervention) {
|
||||||
|
$img = Image::make($source);
|
||||||
|
$mime = $img->mime();
|
||||||
|
} else {
|
||||||
|
$info = @getimagesize($source);
|
||||||
|
$mime = $info['mime'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($mime, $this->allowed, true)) {
|
||||||
|
$this->line("[reject] user={$user->id} unsupported mime={$mime}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-encode full original to webp (strip metadata)
|
||||||
|
if ($useIntervention) {
|
||||||
|
$originalBlob = (string) $img->encode('webp', 82);
|
||||||
|
} else {
|
||||||
|
$originalBlob = $this->gdEncodeWebp($source, 82);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hybrid hash: deterministic user-id fingerprint + short content fingerprint
|
||||||
|
// idPart = sha1(zero-padded user id), contentPart = first 12 chars of sha1(original webp blob)
|
||||||
|
$idPart = sha1(sprintf('%08d', $user->id));
|
||||||
|
$contentPart = substr(sha1($originalBlob), 0, 12);
|
||||||
|
$hash = sprintf('%s_%s', $idPart, $contentPart);
|
||||||
|
|
||||||
|
// Precompute storage dir for dry-run and real run
|
||||||
|
$hashPrefix1 = substr($hash, 0, 2);
|
||||||
|
$hashPrefix2 = substr($hash, 2, 2);
|
||||||
|
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
|
||||||
|
|
||||||
|
// CDN base for public URLs
|
||||||
|
$cdnBase = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$absPathDry = Storage::disk('public')->path("{$dir}/original.webp");
|
||||||
|
$publicUrlDry = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
|
||||||
|
$this->line("[dry] user={$user->id} would write avatars for hash={$hash} path={$absPathDry} url={$publicUrlDry}");
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// Save original.webp
|
||||||
|
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
|
||||||
|
|
||||||
|
// Generate sizes
|
||||||
|
foreach ($this->sizes as $size) {
|
||||||
|
if ($useIntervention) {
|
||||||
|
$thumb = Image::make($source)->fit($size, $size, function ($constraint) {
|
||||||
|
$constraint->upsize();
|
||||||
|
});
|
||||||
|
|
||||||
|
$thumbBlob = (string) $thumb->encode('webp', 82);
|
||||||
|
} else {
|
||||||
|
$thumbBlob = $this->gdCreateThumbnailWebp($source, $size, 82);
|
||||||
|
}
|
||||||
|
Storage::disk('public')->put("{$dir}/{$size}.webp", $thumbBlob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DB
|
||||||
|
$profile->avatar_hash = $hash;
|
||||||
|
$profile->avatar_mime = 'image/webp';
|
||||||
|
$profile->avatar_updated_at = Carbon::now();
|
||||||
|
$profile->save();
|
||||||
|
|
||||||
|
$absPath = Storage::disk('public')->path("{$dir}/original.webp");
|
||||||
|
$publicUrl = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
|
||||||
|
$this->line("[ok] user={$user->id} migrated hash={$hash} path={$absPath} url={$publicUrl}");
|
||||||
|
|
||||||
|
if ($removeLegacy && !empty($profile->avatar_legacy)) {
|
||||||
|
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
|
||||||
|
if (file_exists($legacyFile)) {
|
||||||
|
@unlink($legacyFile);
|
||||||
|
$this->line("[rm] removed legacy file {$legacyFile}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error("[error] user={$user->id} {$e->getMessage()}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info('Avatar migration complete');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to find a legacy avatar file for a user/profile.
|
||||||
|
*
|
||||||
|
* @param UserProfile $profile
|
||||||
|
* @param int $userId
|
||||||
|
* @param string $legacyBase
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase, ?string $legacyConnection = null): ?string
|
||||||
|
{
|
||||||
|
|
||||||
|
$avatar = DB::connection('legacy')->table('users')->where('user_id', $userId)->value('icon');
|
||||||
|
|
||||||
|
if (!empty($profile->avatar_legacy)) {
|
||||||
|
$p = $legacyBase . DIRECTORY_SEPARATOR . $avatar;
|
||||||
|
if (file_exists($p)) {
|
||||||
|
return $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 1) If profile->avatar_legacy looks like a filename, try it
|
||||||
|
if (!empty($profile->avatar_legacy)) {
|
||||||
|
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
|
||||||
|
if (file_exists($p)) {
|
||||||
|
return $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Try files named by user id with common extensions
|
||||||
|
$exts = ['png','jpg','jpeg','webp','gif'];
|
||||||
|
foreach ($exts as $ext) {
|
||||||
|
$p = $legacyBase . DIRECTORY_SEPARATOR . "{$userId}.{$ext}";
|
||||||
|
if (file_exists($p)) {
|
||||||
|
return $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Try any file under legacy dir that contains the user id in name
|
||||||
|
if (is_dir($legacyBase)) {
|
||||||
|
$files = glob($legacyBase . DIRECTORY_SEPARATOR . "*{$userId}*.*");
|
||||||
|
if (!empty($files)) {
|
||||||
|
return $files[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Fallback: try legacy database connection (connection name 'legacy')
|
||||||
|
// If a legacy DB connection is configured, query `users.icon` for avatar filename.
|
||||||
|
try {
|
||||||
|
$conn = $legacyConnection ?: (config('database.connections.legacy') ? 'legacy' : null);
|
||||||
|
if ($conn) {
|
||||||
|
$icon = DB::connection($conn)->table('users')->where('id', $userId)->value('icon');
|
||||||
|
if (!empty($icon)) {
|
||||||
|
// If icon looks like an absolute path, use it directly; otherwise resolve under legacy base path
|
||||||
|
$p = $icon;
|
||||||
|
if (!file_exists($p)) {
|
||||||
|
$p = $legacyBase . DIRECTORY_SEPARATOR . ltrim($icon, '\/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_exists($p)) {
|
||||||
|
if ($this->output->isVerbose()) {
|
||||||
|
$this->line("[legacy-db] user={$userId} icon={$icon} resolved={$p}");
|
||||||
|
}
|
||||||
|
return $p;
|
||||||
|
}
|
||||||
|
if ($this->output->isVerbose()) {
|
||||||
|
$this->line("[legacy-db] user={$userId} icon={$icon} not found at resolved path {$p}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Non-fatal: just skip legacy DB if query fails or connection missing
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GD-based encode to WebP binary blob.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @param int $quality
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function gdEncodeWebp(string $path, int $quality = 82): string
|
||||||
|
{
|
||||||
|
if (!function_exists('imagewebp')) {
|
||||||
|
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$src = $this->gdCreateResource($path);
|
||||||
|
if (!$src) {
|
||||||
|
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagewebp($src, null, $quality);
|
||||||
|
$data = ob_get_clean();
|
||||||
|
imagedestroy($src);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a center-cropped square thumbnail and return WebP binary.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @param int $size
|
||||||
|
* @param int $quality
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function gdCreateThumbnailWebp(string $path, int $size, int $quality = 82): string
|
||||||
|
{
|
||||||
|
if (!function_exists('imagewebp')) {
|
||||||
|
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$src = $this->gdCreateResource($path);
|
||||||
|
if (!$src) {
|
||||||
|
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$w = imagesx($src);
|
||||||
|
$h = imagesy($src);
|
||||||
|
$min = min($w, $h);
|
||||||
|
$srcX = (int) floor(($w - $min) / 2);
|
||||||
|
$srcY = (int) floor(($h - $min) / 2);
|
||||||
|
|
||||||
|
$dst = imagecreatetruecolor($size, $size);
|
||||||
|
// preserve transparency
|
||||||
|
imagealphablending($dst, false);
|
||||||
|
imagesavealpha($dst, true);
|
||||||
|
|
||||||
|
imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $size, $size, $min, $min);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagewebp($dst, null, $quality);
|
||||||
|
$data = ob_get_clean();
|
||||||
|
|
||||||
|
imagedestroy($src);
|
||||||
|
imagedestroy($dst);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create GD image resource from file path.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @return resource|false
|
||||||
|
*/
|
||||||
|
protected function gdCreateResource(string $path)
|
||||||
|
{
|
||||||
|
$info = @getimagesize($path);
|
||||||
|
if (!$info) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = $info['mime'] ?? '';
|
||||||
|
|
||||||
|
switch ($mime) {
|
||||||
|
case 'image/jpeg':
|
||||||
|
return imagecreatefromjpeg($path);
|
||||||
|
case 'image/png':
|
||||||
|
return imagecreatefrompng($path);
|
||||||
|
case 'image/webp':
|
||||||
|
if (function_exists('imagecreatefromwebp')) {
|
||||||
|
return imagecreatefromwebp($path);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
case 'image/gif':
|
||||||
|
if (function_exists('imagecreatefromgif')) {
|
||||||
|
$res = imagecreatefromgif($path);
|
||||||
|
if (!$res) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure returned resource is truecolor (WebP requires truecolor)
|
||||||
|
if (!imageistruecolor($res)) {
|
||||||
|
$w = imagesx($res);
|
||||||
|
$h = imagesy($res);
|
||||||
|
$true = imagecreatetruecolor($w, $h);
|
||||||
|
|
||||||
|
// Preserve transparency where possible
|
||||||
|
imagealphablending($true, false);
|
||||||
|
imagesavealpha($true, true);
|
||||||
|
|
||||||
|
// Fill with fully transparent color
|
||||||
|
$transparent = imagecolorallocatealpha($true, 0, 0, 0, 127);
|
||||||
|
imagefilledrectangle($true, 0, 0, $w, $h, $transparent);
|
||||||
|
|
||||||
|
// If the source has an indexed transparent color, try to preserve it
|
||||||
|
$transIndex = imagecolortransparent($res);
|
||||||
|
if ($transIndex >= 0) {
|
||||||
|
try {
|
||||||
|
$colorTotal = imagecolorstotal($res);
|
||||||
|
if ($transIndex >= 0 && $transIndex < $colorTotal) {
|
||||||
|
$colors = imagecolorsforindex($res, $transIndex);
|
||||||
|
if (is_array($colors)) {
|
||||||
|
$alphaColor = imagecolorallocatealpha($true, $colors['red'], $colors['green'], $colors['blue'], 127);
|
||||||
|
imagefilledrectangle($true, 0, 0, $w, $h, $alphaColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Non-fatal: skip preserving indexed transparent color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy pixels
|
||||||
|
imagecopy($true, $res, 0, 0, 0, 0, $w, $h);
|
||||||
|
imagedestroy($res);
|
||||||
|
return $true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
28
app/Console/Commands/BackfillArtworkEmbeddingsCommand.php
Normal file
28
app/Console/Commands/BackfillArtworkEmbeddingsCommand.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\BackfillArtworkEmbeddingsJob;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
final class BackfillArtworkEmbeddingsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:embeddings-backfill {--after-id=0 : Resume after this artwork id} {--batch=200 : Batch size for resumable fan-out} {--force : Regenerate even when source hash matches}';
|
||||||
|
|
||||||
|
protected $description = 'Queue resumable CLIP embedding backfill for artworks';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$afterId = max(0, (int) $this->option('after-id'));
|
||||||
|
$batch = max(1, min((int) $this->option('batch'), 1000));
|
||||||
|
$force = (bool) $this->option('force');
|
||||||
|
|
||||||
|
BackfillArtworkEmbeddingsJob::dispatch($afterId, $batch, $force);
|
||||||
|
|
||||||
|
$this->info("Queued artwork embedding backfill (after_id={$afterId}, batch={$batch}, force=" . ($force ? 'yes' : 'no') . ').');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Console/Commands/CompareFeedAbCommand.php
Normal file
71
app/Console/Commands/CompareFeedAbCommand.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Recommendations\FeedOfflineEvaluationService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
final class CompareFeedAbCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:compare-feed-ab
|
||||||
|
{baseline : Baseline algo_version}
|
||||||
|
{candidate : Candidate algo_version}
|
||||||
|
{--from= : Start date (Y-m-d), defaults to last 30 days}
|
||||||
|
{--to= : End date (Y-m-d), defaults to today}
|
||||||
|
{--json : Output as JSON}';
|
||||||
|
|
||||||
|
protected $description = 'A/B helper for baseline vs candidate feed algo comparison';
|
||||||
|
|
||||||
|
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
|
||||||
|
$to = (string) ($this->option('to') ?: now()->toDateString());
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
$this->error('Invalid range: --from must be <= --to');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseline = (string) $this->argument('baseline');
|
||||||
|
$candidate = (string) $this->argument('candidate');
|
||||||
|
|
||||||
|
$comparison = $this->evaluator->compareBaselineCandidate($baseline, $candidate, $from, $to);
|
||||||
|
|
||||||
|
if ((bool) $this->option('json')) {
|
||||||
|
$this->line((string) json_encode($comparison, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
|
||||||
|
[[
|
||||||
|
(string) $comparison['baseline']['algo_version'],
|
||||||
|
(float) $comparison['baseline']['ctr'],
|
||||||
|
(float) $comparison['baseline']['save_rate'],
|
||||||
|
(float) $comparison['baseline']['long_dwell_share'],
|
||||||
|
(float) $comparison['baseline']['bounce_rate'],
|
||||||
|
(float) $comparison['baseline']['objective_score'],
|
||||||
|
], [
|
||||||
|
(string) $comparison['candidate']['algo_version'],
|
||||||
|
(float) $comparison['candidate']['ctr'],
|
||||||
|
(float) $comparison['candidate']['save_rate'],
|
||||||
|
(float) $comparison['candidate']['long_dwell_share'],
|
||||||
|
(float) $comparison['candidate']['bounce_rate'],
|
||||||
|
(float) $comparison['candidate']['objective_score'],
|
||||||
|
]]
|
||||||
|
);
|
||||||
|
|
||||||
|
$delta = (array) $comparison['delta'];
|
||||||
|
$this->line('Δ objective_score: ' . (string) $delta['objective_score']);
|
||||||
|
$this->line('Δ objective_lift_pct: ' . (string) ($delta['objective_lift_pct'] ?? 'n/a'));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Console/Commands/ConfigureMeilisearchIndex.php
Normal file
81
app/Console/Commands/ConfigureMeilisearchIndex.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Meilisearch\Client as MeilisearchClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the Meilisearch artworks index:
|
||||||
|
* – sortable attributes (all fields used in category/discover sorts)
|
||||||
|
* – filterable attributes (used in search filters)
|
||||||
|
*
|
||||||
|
* Run after any schema / toSearchableArray change:
|
||||||
|
* php artisan meilisearch:configure-index
|
||||||
|
*/
|
||||||
|
class ConfigureMeilisearchIndex extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'meilisearch:configure-index {--index=artworks : Meilisearch index name}';
|
||||||
|
protected $description = 'Push sortable and filterable attribute settings to the Meilisearch artworks index.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields that can be used as sort targets in Artwork::search()->options(['sort' => …]).
|
||||||
|
* Must match keys in Artwork::toSearchableArray().
|
||||||
|
*/
|
||||||
|
private const SORTABLE_ATTRIBUTES = [
|
||||||
|
'created_at',
|
||||||
|
'trending_score_24h',
|
||||||
|
'trending_score_7d',
|
||||||
|
'favorites_count',
|
||||||
|
'downloads_count',
|
||||||
|
'awards_received_count',
|
||||||
|
'views',
|
||||||
|
'likes',
|
||||||
|
'downloads',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields used in filter expressions (AND category = "…" etc.).
|
||||||
|
*/
|
||||||
|
private const FILTERABLE_ATTRIBUTES = [
|
||||||
|
'id',
|
||||||
|
'is_public',
|
||||||
|
'is_approved',
|
||||||
|
'category',
|
||||||
|
'content_type',
|
||||||
|
'tags',
|
||||||
|
'author_id',
|
||||||
|
'orientation',
|
||||||
|
'resolution',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$prefix = config('scout.prefix', '');
|
||||||
|
$indexName = $prefix . (string) $this->option('index');
|
||||||
|
|
||||||
|
/** @var MeilisearchClient $client */
|
||||||
|
$client = app(MeilisearchClient::class);
|
||||||
|
|
||||||
|
$index = $client->index($indexName);
|
||||||
|
|
||||||
|
$this->info("Configuring Meilisearch index: {$indexName}");
|
||||||
|
|
||||||
|
// ── Sortable attributes ───────────────────────────────────────────────
|
||||||
|
$this->line(' → Updating sortableAttributes…');
|
||||||
|
$task = $index->updateSortableAttributes(self::SORTABLE_ATTRIBUTES);
|
||||||
|
$this->line(" Task uid: {$task['taskUid']}");
|
||||||
|
|
||||||
|
// ── Filterable attributes ─────────────────────────────────────────────
|
||||||
|
$this->line(' → Updating filterableAttributes…');
|
||||||
|
$task2 = $index->updateFilterableAttributes(self::FILTERABLE_ATTRIBUTES);
|
||||||
|
$this->line(" Task uid: {$task2['taskUid']}");
|
||||||
|
|
||||||
|
$this->info('Done. Meilisearch will process these tasks asynchronously.');
|
||||||
|
$this->warn('Re-index artworks if sortable attributes changed: php artisan artworks:search-rebuild');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/Console/Commands/EnforceUsernamePolicy.php
Normal file
96
app/Console/Commands/EnforceUsernamePolicy.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\UsernamePolicy;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class EnforceUsernamePolicy extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:enforce-usernames {--dry-run : Report only, no writes}';
|
||||||
|
|
||||||
|
protected $description = 'Normalize and enforce username policy on existing users, with collision resolution and redirect logging.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$logPath = storage_path('logs/username_migration.log');
|
||||||
|
@file_put_contents($logPath, '['.now()."] enforce-usernames dry_run=".($dryRun ? '1' : '0')."\n", FILE_APPEND);
|
||||||
|
|
||||||
|
$used = User::query()->whereNotNull('username')->pluck('id', 'username')->mapWithKeys(fn ($id, $username) => [strtolower((string) $username) => (int) $id])->all();
|
||||||
|
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
User::query()->orderBy('id')->chunkById(500, function ($users) use (&$used, &$updated, $dryRun, $logPath): void {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$current = strtolower(trim((string) ($user->username ?? '')));
|
||||||
|
$base = UsernamePolicy::sanitizeLegacy($current !== '' ? $current : ('user'.$user->id));
|
||||||
|
|
||||||
|
if (UsernamePolicy::isReserved($base) || UsernamePolicy::similarReserved($base) !== null) {
|
||||||
|
$base = 'user'.$user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = substr($base, 0, UsernamePolicy::max());
|
||||||
|
$suffix = 1;
|
||||||
|
while ((isset($used[$candidate]) && (int) $used[$candidate] !== (int) $user->id) || UsernamePolicy::isReserved($candidate) || UsernamePolicy::similarReserved($candidate) !== null) {
|
||||||
|
$suffixStr = (string) $suffix;
|
||||||
|
$prefixLen = max(1, UsernamePolicy::max() - strlen($suffixStr));
|
||||||
|
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
|
||||||
|
$suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$needsUpdate = $candidate !== $current;
|
||||||
|
if (! $needsUpdate) {
|
||||||
|
$used[$candidate] = (int) $user->id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@file_put_contents($logPath, sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), (int) $user->id, $current, $candidate), FILE_APPEND);
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
DB::transaction(function () use ($user, $current, $candidate): void {
|
||||||
|
if ($current !== '' && Schema::hasTable('username_history')) {
|
||||||
|
DB::table('username_history')->insert([
|
||||||
|
'user_id' => (int) $user->id,
|
||||||
|
'old_username' => $current,
|
||||||
|
'changed_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($current !== '' && Schema::hasTable('username_redirects')) {
|
||||||
|
DB::table('username_redirects')->updateOrInsert(
|
||||||
|
['old_username' => $current],
|
||||||
|
[
|
||||||
|
'new_username' => $candidate,
|
||||||
|
'user_id' => (int) $user->id,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('users')->where('id', (int) $user->id)->update([
|
||||||
|
'username' => $candidate,
|
||||||
|
'username_changed_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$used[$candidate] = (int) $user->id;
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("Username policy enforcement complete. Updated: {$updated}" . ($dryRun ? ' (dry run)' : ''));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Console/Commands/EvaluateFeedWeightsCommand.php
Normal file
81
app/Console/Commands/EvaluateFeedWeightsCommand.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Recommendations\FeedOfflineEvaluationService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
final class EvaluateFeedWeightsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:evaluate-feed-weights
|
||||||
|
{--algo= : Optional algo_version to evaluate}
|
||||||
|
{--from= : Start date (Y-m-d), defaults to last 30 days}
|
||||||
|
{--to= : End date (Y-m-d), defaults to today}
|
||||||
|
{--json : Output as JSON}';
|
||||||
|
|
||||||
|
protected $description = 'Offline feed weight evaluation using feed_daily_metrics';
|
||||||
|
|
||||||
|
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
|
||||||
|
$to = (string) ($this->option('to') ?: now()->toDateString());
|
||||||
|
$algo = $this->option('algo') ? (string) $this->option('algo') : null;
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
$this->error('Invalid range: --from must be <= --to');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($algo !== null && $algo !== '') {
|
||||||
|
$result = $this->evaluator->evaluateAlgo($algo, $from, $to);
|
||||||
|
|
||||||
|
if ((bool) $this->option('json')) {
|
||||||
|
$this->line((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
} else {
|
||||||
|
$this->table(
|
||||||
|
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
|
||||||
|
[[
|
||||||
|
(string) $result['algo_version'],
|
||||||
|
(float) $result['ctr'],
|
||||||
|
(float) $result['save_rate'],
|
||||||
|
(float) $result['long_dwell_share'],
|
||||||
|
(float) $result['bounce_rate'],
|
||||||
|
(float) $result['objective_score'],
|
||||||
|
]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $this->evaluator->evaluateAll($from, $to);
|
||||||
|
|
||||||
|
if ((bool) $this->option('json')) {
|
||||||
|
$this->line((string) json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = array_map(static fn (array $row): array => [
|
||||||
|
(string) $row['algo_version'],
|
||||||
|
(float) $row['ctr'],
|
||||||
|
(float) $row['save_rate'],
|
||||||
|
(float) $row['long_dwell_share'],
|
||||||
|
(float) $row['bounce_rate'],
|
||||||
|
(float) $row['objective_score'],
|
||||||
|
], $results);
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Console/Commands/ExportMissingTranslations.php
Normal file
108
app/Console/Commands/ExportMissingTranslations.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Klevze\ControlPanel\Facades\FileManager;
|
||||||
|
use Klevze\ControlPanel\Core\Utils\Translation as TranslationUtil;
|
||||||
|
|
||||||
|
class ExportMissingTranslations extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'translations:export-missing {file=admin} {--out=}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Export missing translations for a file (e.g. admin) into a CSV';
|
||||||
|
|
||||||
|
private $translationURL = "https://cPad.dev/api/translation/get/list";
|
||||||
|
private $token = 'Ddt06xvjYX1TK792H4jAtld8UhgVORYIpkB7nBX6';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$type = $this->argument('file') ?? 'admin';
|
||||||
|
$this->info('Exporting missing translations for: ' . $type);
|
||||||
|
|
||||||
|
// Gather files to scan
|
||||||
|
$files = [];
|
||||||
|
$files = array_merge(
|
||||||
|
FileManager::getFileList(app_path(), true),
|
||||||
|
FileManager::getFileList(base_path('packages'), true),
|
||||||
|
FileManager::getFileList(resource_path(), true)
|
||||||
|
);
|
||||||
|
|
||||||
|
$tempTranslations = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$res = TranslationUtil::findTranslations($file, $type);
|
||||||
|
if (!empty($res) && is_array($res)) {
|
||||||
|
$tempTranslations[] = $res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempTranslations = collect($tempTranslations)->collapse();
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
foreach ($tempTranslations as $keycode => $row) {
|
||||||
|
$exists = DB::table('translations')->where('keycode', $keycode)->where('file', $type)->exists();
|
||||||
|
if (! $exists) {
|
||||||
|
$missing[] = $keycode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Found ' . count($missing) . ' missing keys');
|
||||||
|
|
||||||
|
// Fetch suggested translations from external service for sl and en
|
||||||
|
$suggestions = [];
|
||||||
|
if (!empty($missing)) {
|
||||||
|
$payload = [
|
||||||
|
'keys' => $missing,
|
||||||
|
'languages' => ['sl', 'en'],
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resp = Http::withToken($this->token)->post($this->translationURL, $payload);
|
||||||
|
if ($resp->successful()) {
|
||||||
|
$suggestions = $resp->json();
|
||||||
|
} else {
|
||||||
|
$this->warn('Translation suggestion service returned ' . $resp->status());
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->warn('Failed to call suggestion service: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build CSV
|
||||||
|
$out = $this->option('out') ?: storage_path('app/translations_missing_' . $type . '.csv');
|
||||||
|
$fh = fopen($out, 'w');
|
||||||
|
if (! $fh) {
|
||||||
|
$this->error('Failed to open output file: ' . $out);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
fputcsv($fh, ['file','keycode','suggested_sl','suggested_en','placeholder']);
|
||||||
|
|
||||||
|
foreach ($missing as $key) {
|
||||||
|
$s_sl = $suggestions[$key]['sl'] ?? '';
|
||||||
|
$s_en = $suggestions[$key]['en'] ?? '';
|
||||||
|
$placeholder = $type . '.' . $key;
|
||||||
|
fputcsv($fh, [$type, $key, $s_sl, $s_en, $placeholder]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
|
$this->info('CSV exported to: ' . $out);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Console/Commands/FixTagNamesCommand.php
Normal file
92
app/Console/Commands/FixTagNamesCommand.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\TagNormalizer;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time (and idempotent) command to convert slug-style tag names to
|
||||||
|
* human-readable display names.
|
||||||
|
*
|
||||||
|
* A tag is considered "slug-style" when its name is identical to its slug
|
||||||
|
* (e.g. name="digital-art", slug="digital-art"). Tags that already have a
|
||||||
|
* custom name (user-edited) are left untouched.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan tags:fix-names
|
||||||
|
* php artisan tags:fix-names --dry-run
|
||||||
|
*/
|
||||||
|
final class FixTagNamesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tags:fix-names
|
||||||
|
{--dry-run : Show what would change without writing to the database}
|
||||||
|
';
|
||||||
|
|
||||||
|
protected $description = 'Convert slug-style tag names (e.g. "digital-art") to readable names ("Digital Art")';
|
||||||
|
|
||||||
|
public function __construct(private readonly TagNormalizer $normalizer)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY-RUN — no changes will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fix rows where name === slug (those were created by the old code).
|
||||||
|
$rows = DB::table('tags')
|
||||||
|
->whereColumn('name', 'slug')
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['id', 'name', 'slug']);
|
||||||
|
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
$this->info('Nothing to fix — all tag names are already human-readable.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$rows->count()} tag(s) with slug-style names.");
|
||||||
|
|
||||||
|
$updated = 0;
|
||||||
|
$bar = $this->output->createProgressBar($rows->count());
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$displayName = $this->normalizer->toDisplayName($row->slug);
|
||||||
|
|
||||||
|
if ($displayName === $row->name) {
|
||||||
|
$bar->advance();
|
||||||
|
continue; // Already correct (e.g. single-word tag "cars" → "Cars" — wait, that would differ)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->output->isVerbose()) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line(" {$row->slug} → \"{$displayName}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
DB::table('tags')
|
||||||
|
->where('id', $row->id)
|
||||||
|
->update(['name' => $displayName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated++;
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
$suffix = $dryRun ? ' (dry-run, nothing written)' : '';
|
||||||
|
$this->info("Updated {$updated} of {$rows->count()} tag(s){$suffix}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Console/Commands/FlushRedisStatsCommand.php
Normal file
44
app/Console/Commands/FlushRedisStatsCommand.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\ArtworkStatsService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain the Redis artwork-stat delta queue into MySQL.
|
||||||
|
*
|
||||||
|
* The ArtworkStatsService::incrementViews/Downloads methods push compressed
|
||||||
|
* delta payloads to a Redis list (`artwork_stats:deltas`) when Redis is
|
||||||
|
* available. This command drains that queue by applying each delta to the
|
||||||
|
* artwork_stats table via applyDelta().
|
||||||
|
*
|
||||||
|
* Designed to run every 5 minutes so counters stay reasonably fresh while
|
||||||
|
* keeping MySQL write pressure low. If Redis is unavailable the command exits
|
||||||
|
* immediately without error — the service already fell back to direct DB
|
||||||
|
* writes in that case.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan skinbase:flush-redis-stats
|
||||||
|
* php artisan skinbase:flush-redis-stats --max=500
|
||||||
|
*/
|
||||||
|
class FlushRedisStatsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:flush-redis-stats {--max=1000 : Maximum deltas to process per run}';
|
||||||
|
protected $description = 'Drain Redis artwork stat delta queue into MySQL';
|
||||||
|
|
||||||
|
public function handle(ArtworkStatsService $service): int
|
||||||
|
{
|
||||||
|
$max = (int) $this->option('max');
|
||||||
|
|
||||||
|
$processed = $service->processPendingFromRedis($max);
|
||||||
|
|
||||||
|
if ($this->getOutput()->isVerbose()) {
|
||||||
|
$this->info("Processed {$processed} artwork-stat delta(s) from Redis.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Console/Commands/ForumConvertPosts.php
Normal file
78
app/Console/Commands/ForumConvertPosts.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use App\Models\ForumPost;
|
||||||
|
use App\Services\BbcodeConverter;
|
||||||
|
|
||||||
|
class ForumConvertPosts extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'forum:convert-posts {--dry-run} {--chunk=500} {--limit=} {--report}';
|
||||||
|
|
||||||
|
protected $description = 'Convert migrated forum posts content from legacy BBCode to HTML in-place';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dry = $this->option('dry-run');
|
||||||
|
$chunk = (int)$this->option('chunk');
|
||||||
|
$limit = $this->option('limit') ? (int)$this->option('limit') : null;
|
||||||
|
|
||||||
|
$query = ForumPost::query()->orderBy('id');
|
||||||
|
$total = $limit ? min($query->count(), $limit) : $query->count();
|
||||||
|
|
||||||
|
$this->info('Converting forum posts (dry-run='.($dry ? 'yes' : 'no').')');
|
||||||
|
$this->info("Total posts to consider: {$total}");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
$converter = new BbcodeConverter();
|
||||||
|
$processed = 0;
|
||||||
|
$changed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$query->chunkById($chunk, function ($posts) use (&$bar, &$processed, &$changed, $dry, $limit, $converter) {
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
if ($limit !== null && $processed >= $limit) {
|
||||||
|
throw new \RuntimeException('limit_reached');
|
||||||
|
}
|
||||||
|
$bar->advance();
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
$old = $post->content ?? '';
|
||||||
|
$new = $converter->convert($old);
|
||||||
|
|
||||||
|
if ($old === $new) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed++;
|
||||||
|
if ($dry) {
|
||||||
|
$this->line('[dry] would update post ' . $post->id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$post->content = $new;
|
||||||
|
$post->save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
if ($e->getMessage() !== 'limit_reached') {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
// intentionally stop chunking when limit reached
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
$this->info("Processed: {$processed} posts. Changed: {$changed} posts.");
|
||||||
|
|
||||||
|
if ($this->option('report')) {
|
||||||
|
$this->info('Conversion complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
624
app/Console/Commands/ForumMigrateOld.php
Normal file
624
app/Console/Commands/ForumMigrateOld.php
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use App\Models\ForumCategory;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\ForumThread;
|
||||||
|
use App\Models\ForumPost;
|
||||||
|
use Exception;
|
||||||
|
use App\Services\BbcodeConverter;
|
||||||
|
|
||||||
|
class ForumMigrateOld extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report} {--repair-orphans}';
|
||||||
|
|
||||||
|
protected $description = 'Migrate legacy forum data from legacy DB into new forum tables';
|
||||||
|
|
||||||
|
protected string $logPath;
|
||||||
|
|
||||||
|
protected ?int $limit = null;
|
||||||
|
|
||||||
|
protected ?int $deletedUserId = null;
|
||||||
|
|
||||||
|
/** @var array<int,int> */
|
||||||
|
protected array $missingUserIds = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->logPath = storage_path('logs/forum_migration.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$this->info('Starting forum migration');
|
||||||
|
$this->log('Starting forum migration');
|
||||||
|
|
||||||
|
$dry = $this->option('dry-run');
|
||||||
|
$only = $this->option('only');
|
||||||
|
$chunk = (int)$this->option('chunk');
|
||||||
|
$this->limit = $this->option('limit') !== null ? max(0, (int) $this->option('limit')) : null;
|
||||||
|
|
||||||
|
$only = $only === 'attachments' ? 'gallery' : $only;
|
||||||
|
if ($only && !in_array($only, ['categories', 'threads', 'posts', 'gallery', 'repair-orphans'], true)) {
|
||||||
|
$this->error('Invalid --only value. Allowed: categories, threads, posts, gallery (or attachments), repair-orphans.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($chunk < 1) {
|
||||||
|
$chunk = 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!$only || $only === 'categories') {
|
||||||
|
$this->migrateCategories($dry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$only || $only === 'threads') {
|
||||||
|
$this->migrateThreads($dry, $chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$only || $only === 'posts') {
|
||||||
|
$this->migratePosts($dry, $chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$only || $only === 'gallery') {
|
||||||
|
$this->migrateGallery($dry, $chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('repair-orphans') || $only === 'repair-orphans') {
|
||||||
|
$this->repairOrphanPosts($dry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('report')) {
|
||||||
|
$this->generateReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Forum migration finished');
|
||||||
|
$this->log('Forum migration finished');
|
||||||
|
return 0;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->error('Migration failed: ' . $e->getMessage());
|
||||||
|
$this->log('Migration failed: ' . $e->getMessage());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function migrateCategories(bool $dry)
|
||||||
|
{
|
||||||
|
$this->info('Migrating categories');
|
||||||
|
$legacy = DB::connection('legacy');
|
||||||
|
|
||||||
|
$roots = $legacy->table('forum_topics')
|
||||||
|
->select('root_id')
|
||||||
|
->distinct()
|
||||||
|
->where('root_id', '>', 0)
|
||||||
|
->orderBy('root_id')
|
||||||
|
->pluck('root_id');
|
||||||
|
|
||||||
|
if ($this->limit !== null && $this->limit > 0) {
|
||||||
|
$roots = $roots->take($this->limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Found ' . $roots->count() . ' legacy root ids');
|
||||||
|
|
||||||
|
foreach ($roots as $rootId) {
|
||||||
|
$row = $legacy->table('forum_topics')->where('topic_id', $rootId)->first();
|
||||||
|
$name = $row->topic ?? 'Category ' . $rootId;
|
||||||
|
$slug = Str::slug(substr($name, 0, 150));
|
||||||
|
|
||||||
|
$this->line("-> root {$rootId}: {$name}");
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$this->log("[dry] create category {$name} ({$slug})");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($rootId, $name, $slug) {
|
||||||
|
ForumCategory::updateOrCreate(
|
||||||
|
['id' => $rootId],
|
||||||
|
['name' => $name, 'slug' => $slug]
|
||||||
|
);
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Categories migrated');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function migrateThreads(bool $dry, int $chunk)
|
||||||
|
{
|
||||||
|
$this->info('Migrating threads');
|
||||||
|
$legacy = DB::connection('legacy');
|
||||||
|
|
||||||
|
$query = $legacy->table('forum_topics')->orderBy('topic_id');
|
||||||
|
|
||||||
|
$total = $query->count();
|
||||||
|
if ($this->limit !== null && $this->limit > 0) {
|
||||||
|
$total = min($total, $this->limit);
|
||||||
|
}
|
||||||
|
$this->info("Total threads to process: {$total}");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$limit = $this->limit;
|
||||||
|
|
||||||
|
// chunk by legacy primary key `topic_id`
|
||||||
|
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ($limit !== null && $limit > 0 && $processed >= $limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $r->topic_id,
|
||||||
|
'category_id' => $this->resolveCategoryId($r->root_id ?? null, $r->topic_id),
|
||||||
|
// resolve user id or assign to system user (1) when missing or not found
|
||||||
|
'user_id' => $this->resolveUserId($r->user_id ?? null),
|
||||||
|
'title' => $r->topic,
|
||||||
|
'slug' => $this->uniqueSlug(Str::slug(substr($r->topic,0,200)) ?: 'thread-'.$r->topic_id, $r->topic_id),
|
||||||
|
'content' => $r->preview ?? '',
|
||||||
|
'views' => $r->views ?? 0,
|
||||||
|
'is_locked' => isset($r->open) ? !((bool)$r->open) : false,
|
||||||
|
'is_pinned' => false,
|
||||||
|
'visibility' => $this->mapPrivilegeToVisibility($r->privilege ?? 0),
|
||||||
|
'last_post_at' => $this->normalizeDate($r->last_update ?? null),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$this->log('[dry] thread: ' . $r->topic_id . ' - ' . $r->topic);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($data) {
|
||||||
|
ForumThread::updateOrCreate(['id' => $data['id']], $data);
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
}, 'topic_id');
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->line('');
|
||||||
|
$this->info('Threads migrated');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function migratePosts(bool $dry, int $chunk)
|
||||||
|
{
|
||||||
|
$this->info('Migrating posts');
|
||||||
|
$legacy = DB::connection('legacy');
|
||||||
|
|
||||||
|
$query = $legacy->table('forum_posts')->orderBy('post_id');
|
||||||
|
$total = $query->count();
|
||||||
|
if ($this->limit !== null && $this->limit > 0) {
|
||||||
|
$total = min($total, $this->limit);
|
||||||
|
}
|
||||||
|
$this->info("Total posts to process: {$total}");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$limit = $this->limit;
|
||||||
|
|
||||||
|
// legacy forum_posts uses `post_id` as primary key
|
||||||
|
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ($limit !== null && $limit > 0 && $processed >= $limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $r->post_id,
|
||||||
|
'thread_id' => $r->topic_id,
|
||||||
|
'user_id' => $r->user_id ?? null,
|
||||||
|
'content' => $this->convertLegacyMessage($r->message ?? ''),
|
||||||
|
'is_edited' => isset($r->isupdated) ? (bool)$r->isupdated : false,
|
||||||
|
'edited_at' => $r->updated ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$this->log('[dry] post: ' . $r->post_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($data) {
|
||||||
|
ForumPost::updateOrCreate(['id' => $data['id']], $data);
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
}, 'post_id');
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->line('');
|
||||||
|
$this->info('Posts migrated');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mapPrivilegeToVisibility($priv)
|
||||||
|
{
|
||||||
|
// legacy privilege: 0 public, 1 members, 4 staff? adjust mapping conservatively
|
||||||
|
if ($priv >= 4) return 'staff';
|
||||||
|
if ($priv >= 1) return 'members';
|
||||||
|
return 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeDate($val)
|
||||||
|
{
|
||||||
|
if (empty($val)) return null;
|
||||||
|
$s = trim((string)$val);
|
||||||
|
// legacy sometimes contains sentinel invalid dates like -0001-11-30 or zero dates
|
||||||
|
if (strpos($s, '-0001') !== false) return null;
|
||||||
|
if (strpos($s, '0000-00-00') !== false) return null;
|
||||||
|
if (strtotime($s) === false) return null;
|
||||||
|
return date('Y-m-d H:i:s', strtotime($s));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function uniqueSlug(string $base, int $id)
|
||||||
|
{
|
||||||
|
$slug = $base;
|
||||||
|
$i = 0;
|
||||||
|
while (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) {
|
||||||
|
$i++;
|
||||||
|
$slug = $base . '-' . $id;
|
||||||
|
// if somehow still exists, append counter
|
||||||
|
if (ForumThread::where('slug', $slug)->where('id', '<>', $id)->exists()) {
|
||||||
|
$slug = $base . '-' . $id . '-' . $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveCategoryId($rootId, $topicId)
|
||||||
|
{
|
||||||
|
// prefer explicit rootId
|
||||||
|
if (!empty($rootId)) {
|
||||||
|
// ensure category exists
|
||||||
|
if (ForumCategory::where('id', $rootId)->exists()) return $rootId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if this topic itself is a category
|
||||||
|
if (ForumCategory::where('id', $topicId)->exists()) return $topicId;
|
||||||
|
|
||||||
|
// fallback: use first available category
|
||||||
|
$first = ForumCategory::first();
|
||||||
|
if ($first) return $first->id;
|
||||||
|
|
||||||
|
// as last resort, create Uncategorized
|
||||||
|
$cat = ForumCategory::create(['name' => 'Uncategorized', 'slug' => 'uncategorized']);
|
||||||
|
return $cat->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveUserId($userId)
|
||||||
|
{
|
||||||
|
if (empty($userId)) {
|
||||||
|
return $this->resolveDeletedUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// check users table in default connection
|
||||||
|
if (DB::table('users')->where('id', $userId)->exists()) {
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uid = (int) $userId;
|
||||||
|
if ($uid > 0 && !in_array($uid, $this->missingUserIds, true)) {
|
||||||
|
$this->missingUserIds[] = $uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveDeletedUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveDeletedUserId(): int
|
||||||
|
{
|
||||||
|
if ($this->deletedUserId !== null) {
|
||||||
|
return $this->deletedUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userOne = User::query()->find(1);
|
||||||
|
if ($userOne) {
|
||||||
|
$this->deletedUserId = 1;
|
||||||
|
return $this->deletedUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallback = User::query()->orderBy('id')->first();
|
||||||
|
if ($fallback) {
|
||||||
|
$this->deletedUserId = (int) $fallback->id;
|
||||||
|
return $this->deletedUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$created = User::query()->create([
|
||||||
|
'name' => 'Deleted User',
|
||||||
|
'email' => 'deleted-user+forum@skinbase.local',
|
||||||
|
'password' => Hash::make(Str::random(64)),
|
||||||
|
'role' => 'user',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->deletedUserId = (int) $created->id;
|
||||||
|
|
||||||
|
return $this->deletedUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function convertLegacyMessage($msg)
|
||||||
|
{
|
||||||
|
$converter = new BbcodeConverter();
|
||||||
|
return $converter->convert($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function repairOrphanPosts(bool $dry): void
|
||||||
|
{
|
||||||
|
$this->info('Repairing orphan posts');
|
||||||
|
|
||||||
|
$orphansQuery = ForumPost::query()->whereDoesntHave('thread')->orderBy('id');
|
||||||
|
$orphanCount = (clone $orphansQuery)->count();
|
||||||
|
|
||||||
|
if ($orphanCount === 0) {
|
||||||
|
$this->info('No orphan posts found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn("Found {$orphanCount} orphan posts.");
|
||||||
|
|
||||||
|
$repairThread = $this->resolveOrCreateOrphanRepairThread($dry);
|
||||||
|
|
||||||
|
if ($repairThread === null) {
|
||||||
|
$this->warn('Unable to resolve/create repair thread in dry-run mode. Reporting only.');
|
||||||
|
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post): void {
|
||||||
|
$this->line("- orphan post id={$post->id} thread_id={$post->thread_id} user_id={$post->user_id}");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line("Repair target thread: {$repairThread->id} ({$repairThread->slug})");
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$this->info("[dry] Would reassign {$orphanCount} orphan posts to thread {$repairThread->id}");
|
||||||
|
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post) use ($repairThread): void {
|
||||||
|
$this->log("[dry] orphan post {$post->id}: {$post->thread_id} -> {$repairThread->id}");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
(clone $orphansQuery)->chunkById(500, function ($posts) use ($repairThread, &$updated): void {
|
||||||
|
DB::transaction(function () use ($posts, $repairThread, &$updated): void {
|
||||||
|
/** @var ForumPost $post */
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
$post->thread_id = $repairThread->id;
|
||||||
|
$post->is_edited = true;
|
||||||
|
$post->edited_at = $post->edited_at ?: now();
|
||||||
|
$post->save();
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
}, 3);
|
||||||
|
}, 'id');
|
||||||
|
|
||||||
|
$latestPostAt = ForumPost::query()
|
||||||
|
->where('thread_id', $repairThread->id)
|
||||||
|
->max('created_at');
|
||||||
|
|
||||||
|
if ($latestPostAt) {
|
||||||
|
$repairThread->last_post_at = $latestPostAt;
|
||||||
|
$repairThread->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Repaired orphan posts: {$updated}");
|
||||||
|
$this->log("Repaired orphan posts: {$updated} -> thread {$repairThread->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveOrCreateOrphanRepairThread(bool $dry): ?ForumThread
|
||||||
|
{
|
||||||
|
$slug = 'migration-orphaned-posts';
|
||||||
|
|
||||||
|
$existing = ForumThread::query()->where('slug', $slug)->first();
|
||||||
|
if ($existing) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = ForumCategory::query()->ordered()->first();
|
||||||
|
|
||||||
|
if (!$category && !$dry) {
|
||||||
|
$category = ForumCategory::query()->create([
|
||||||
|
'name' => 'Migration Repairs',
|
||||||
|
'slug' => 'migration-repairs',
|
||||||
|
'parent_id' => null,
|
||||||
|
'position' => 9999,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$category) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
return new ForumThread([
|
||||||
|
'id' => 0,
|
||||||
|
'slug' => $slug,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ForumThread::query()->create([
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'user_id' => $this->resolveDeletedUserId(),
|
||||||
|
'title' => 'Migration: Orphaned Posts Recovery',
|
||||||
|
'slug' => $slug,
|
||||||
|
'content' => 'Automatic recovery thread for legacy posts whose source thread no longer exists after migration.',
|
||||||
|
'views' => 0,
|
||||||
|
'is_locked' => false,
|
||||||
|
'is_pinned' => false,
|
||||||
|
'visibility' => 'staff',
|
||||||
|
'last_post_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateReport()
|
||||||
|
{
|
||||||
|
$this->info('Generating migration report');
|
||||||
|
$legacy = DB::connection('legacy');
|
||||||
|
|
||||||
|
$legacyCounts = [
|
||||||
|
'categories' => $legacy->table('forum_topics')->where('root_id','>',0)->distinct('root_id')->count('root_id'),
|
||||||
|
'threads' => $legacy->table('forum_topics')->count(),
|
||||||
|
'posts' => $legacy->table('forum_posts')->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$newCounts = [
|
||||||
|
'categories' => ForumCategory::count(),
|
||||||
|
'threads' => ForumThread::count(),
|
||||||
|
'posts' => ForumPost::count(),
|
||||||
|
'attachments' => DB::table('forum_attachments')->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$orphans = ForumPost::query()
|
||||||
|
->whereDoesntHave('thread')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$legacyThreadsWithLastUpdate = $legacy->table('forum_topics')->whereNotNull('last_update')->count();
|
||||||
|
$newThreadsWithLastPost = ForumThread::query()->whereNotNull('last_post_at')->count();
|
||||||
|
$legacyPostsWithPostDate = $legacy->table('forum_posts')->whereNotNull('post_date')->count();
|
||||||
|
$newPostsWithCreatedAt = ForumPost::query()->whereNotNull('created_at')->count();
|
||||||
|
|
||||||
|
$report = [
|
||||||
|
'missing_users_count' => count($this->missingUserIds),
|
||||||
|
'missing_users' => $this->missingUserIds,
|
||||||
|
'orphan_posts' => $orphans,
|
||||||
|
'timestamp_mismatches' => [
|
||||||
|
'threads_last_post_gap' => max(0, $legacyThreadsWithLastUpdate - $newThreadsWithLastPost),
|
||||||
|
'posts_created_at_gap' => max(0, $legacyPostsWithPostDate - $newPostsWithCreatedAt),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->info('Legacy counts: ' . json_encode($legacyCounts));
|
||||||
|
$this->info('New counts: ' . json_encode($newCounts));
|
||||||
|
$this->info('Report: ' . json_encode($report));
|
||||||
|
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts) . ' extra=' . json_encode($report));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function log(string $msg)
|
||||||
|
{
|
||||||
|
$line = '[' . date('c') . '] ' . $msg . "\n";
|
||||||
|
file_put_contents($this->logPath, $line, FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function migrateGallery(bool $dry, int $chunk)
|
||||||
|
{
|
||||||
|
$this->info('Migrating gallery (forum_topics_gallery → forum_attachments)');
|
||||||
|
$legacy = DB::connection('legacy');
|
||||||
|
|
||||||
|
if (!$legacy->getSchemaBuilder()->hasTable('forum_topics_gallery')) {
|
||||||
|
$this->info('No legacy forum_topics_gallery table found, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $legacy->table('forum_topics_gallery')->orderBy('id');
|
||||||
|
$total = $query->count();
|
||||||
|
if ($this->limit !== null && $this->limit > 0) {
|
||||||
|
$total = min($total, $this->limit);
|
||||||
|
}
|
||||||
|
$this->info("Total gallery items to process: {$total}");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$limit = $this->limit;
|
||||||
|
|
||||||
|
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ($limit !== null && $limit > 0 && $processed >= $limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
// expected legacy fields: id, name, category (topic id), folder, datum, description
|
||||||
|
$topicId = $r->category ?? ($r->topic_id ?? null);
|
||||||
|
$fileName = $r->name ?? null;
|
||||||
|
if (empty($topicId) || empty($fileName)) {
|
||||||
|
$this->log('Skipping gallery row with missing topic or name: ' . json_encode($r));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nid = floor($topicId / 100);
|
||||||
|
$relativePath = "files/news/{$nid}/{$topicId}/{$fileName}";
|
||||||
|
$publicPath = public_path($relativePath);
|
||||||
|
|
||||||
|
$fileSize = null;
|
||||||
|
$mimeType = null;
|
||||||
|
$width = null;
|
||||||
|
$height = null;
|
||||||
|
|
||||||
|
if (file_exists($publicPath)) {
|
||||||
|
$fileSize = filesize($publicPath);
|
||||||
|
$img = @getimagesize($publicPath);
|
||||||
|
if ($img !== false) {
|
||||||
|
$width = $img[0];
|
||||||
|
$height = $img[1];
|
||||||
|
$mimeType = $img['mime'] ?? null;
|
||||||
|
} else {
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = finfo_file($finfo, $publicPath);
|
||||||
|
finfo_close($finfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// find legacy first post id for this topic
|
||||||
|
$legacy = DB::connection('legacy');
|
||||||
|
$firstPostId = $legacy->table('forum_posts')
|
||||||
|
->where('topic_id', $topicId)
|
||||||
|
->orderBy('post_date')
|
||||||
|
->value('post_id');
|
||||||
|
|
||||||
|
// map to new forum_posts id (we preserved ids when migrating)
|
||||||
|
$postId = null;
|
||||||
|
if ($firstPostId && \App\Models\ForumPost::where('id', $firstPostId)->exists()) {
|
||||||
|
$postId = $firstPostId;
|
||||||
|
} else {
|
||||||
|
// fallback: find any post in new DB for thread
|
||||||
|
$post = \App\Models\ForumPost::where('thread_id', $topicId)->orderBy('created_at')->first();
|
||||||
|
if ($post) $postId = $post->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($postId)) {
|
||||||
|
$this->log('No target post found for gallery item: topic ' . $topicId . ' file ' . $fileName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dry) {
|
||||||
|
$this->log("[dry] attach {$relativePath} -> post {$postId}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($postId, $relativePath, $fileSize, $mimeType, $width, $height) {
|
||||||
|
\App\Models\ForumAttachment::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'post_id' => $postId,
|
||||||
|
'file_path' => $relativePath,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file_size' => $fileSize ?? 0,
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
}, 'id');
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->line('');
|
||||||
|
$this->info('Gallery migrated');
|
||||||
|
}
|
||||||
|
}
|
||||||
288
app/Console/Commands/ImportLegacyAwards.php
Normal file
288
app/Console/Commands/ImportLegacyAwards.php
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ArtworkAward;
|
||||||
|
use App\Models\ArtworkAwardStat;
|
||||||
|
use App\Services\ArtworkAwardService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_awards`.
|
||||||
|
*
|
||||||
|
* Score mapping (legacy score → new medal):
|
||||||
|
* 4 → gold (weight 3)
|
||||||
|
* 3 → silver (weight 2)
|
||||||
|
* 2 → bronze (weight 1)
|
||||||
|
* 1 → skipped (too low to map meaningfully)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan awards:import-legacy
|
||||||
|
* php artisan awards:import-legacy --dry-run
|
||||||
|
* php artisan awards:import-legacy --chunk=500
|
||||||
|
* php artisan awards:import-legacy --skip-stats (skip final stats recalc)
|
||||||
|
*/
|
||||||
|
class ImportLegacyAwards extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'awards:import-legacy
|
||||||
|
{--dry-run : Preview only — no writes to DB}
|
||||||
|
{--chunk=250 : Rows to process per batch}
|
||||||
|
{--skip-stats : Skip per-artwork stats recalculation at the end}
|
||||||
|
{--force : Overwrite existing awards instead of skipping duplicates}';
|
||||||
|
|
||||||
|
protected $description = 'Import legacy users_opinions into artwork_awards';
|
||||||
|
|
||||||
|
/** Maps legacy score value → medal string */
|
||||||
|
private const SCORE_MAP = [
|
||||||
|
4 => 'gold',
|
||||||
|
3 => 'silver',
|
||||||
|
2 => 'bronze',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(ArtworkAwardService $service): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$skipStats = (bool) $this->option('skip-stats');
|
||||||
|
$force = (bool) $this->option('force');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('[DRY-RUN] No data will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify legacy connection is reachable
|
||||||
|
try {
|
||||||
|
DB::connection('legacy')->getPdo();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('users_opinions')) {
|
||||||
|
$this->error('Legacy table `users_opinions` not found.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-load sets of valid artwork IDs and user IDs from the new DB
|
||||||
|
$this->info('Loading new-DB artwork and user ID sets…');
|
||||||
|
$validArtworkIds = DB::table('artworks')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->pluck('id')
|
||||||
|
->flip() // flip so we can use isset() for O(1) lookup
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$validUserIds = DB::table('users')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->pluck('id')
|
||||||
|
->flip()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Found %d artworks and %d users in new DB.',
|
||||||
|
count($validArtworkIds),
|
||||||
|
count($validUserIds)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Count legacy rows for progress bar
|
||||||
|
$total = DB::connection('legacy')
|
||||||
|
->table('users_opinions')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->info("Legacy rows to process: {$total}");
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
$this->warn('No legacy rows found. Nothing to do.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'imported' => 0,
|
||||||
|
'skipped_score' => 0,
|
||||||
|
'skipped_artwork' => 0,
|
||||||
|
'skipped_user' => 0,
|
||||||
|
'skipped_duplicate'=> 0,
|
||||||
|
'updated_force' => 0,
|
||||||
|
'errors' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$affectedArtworkIds = [];
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
|
||||||
|
$bar->setMessage('0', 'imported');
|
||||||
|
$bar->setMessage('0', 'skipped');
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
DB::connection('legacy')
|
||||||
|
->table('users_opinions')
|
||||||
|
->orderBy('opinion_id')
|
||||||
|
->chunk($chunk, function ($rows) use (
|
||||||
|
&$stats,
|
||||||
|
&$affectedArtworkIds,
|
||||||
|
$validArtworkIds,
|
||||||
|
$validUserIds,
|
||||||
|
$dryRun,
|
||||||
|
$force,
|
||||||
|
$bar
|
||||||
|
) {
|
||||||
|
$inserts = [];
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$artworkId = (int) $row->artwork_id;
|
||||||
|
$userId = (int) $row->author_id; // author_id = the voter
|
||||||
|
$score = (int) $row->score;
|
||||||
|
$postedAt = $row->post_date ?? $now;
|
||||||
|
|
||||||
|
// --- score → medal ---
|
||||||
|
$medal = self::SCORE_MAP[$score] ?? null;
|
||||||
|
if ($medal === null) {
|
||||||
|
$stats['skipped_score']++;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Artwork must exist in new DB ---
|
||||||
|
if (! isset($validArtworkIds[$artworkId])) {
|
||||||
|
$stats['skipped_artwork']++;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User must exist in new DB ---
|
||||||
|
if (! isset($validUserIds[$userId])) {
|
||||||
|
$stats['skipped_user']++;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
if ($force) {
|
||||||
|
// Upsert: update medal if row already exists
|
||||||
|
$affected = DB::table('artwork_awards')
|
||||||
|
->where('artwork_id', $artworkId)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->update([
|
||||||
|
'medal' => $medal,
|
||||||
|
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($affected > 0) {
|
||||||
|
$stats['updated_force']++;
|
||||||
|
$affectedArtworkIds[$artworkId] = true;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Skip if already exists
|
||||||
|
if (
|
||||||
|
DB::table('artwork_awards')
|
||||||
|
->where('artwork_id', $artworkId)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->exists()
|
||||||
|
) {
|
||||||
|
$stats['skipped_duplicate']++;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$inserts[] = [
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'medal' => $medal,
|
||||||
|
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||||
|
'created_at' => $postedAt,
|
||||||
|
'updated_at' => $postedAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
$affectedArtworkIds[$artworkId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['imported']++;
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk insert the batch (DB::table bypasses the observer intentionally;
|
||||||
|
// stats are recalculated in bulk at the end for performance)
|
||||||
|
if (! $dryRun && ! empty($inserts)) {
|
||||||
|
try {
|
||||||
|
DB::table('artwork_awards')->insert($inserts);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Fallback: insert one-by-one to isolate constraint violations
|
||||||
|
foreach ($inserts as $row) {
|
||||||
|
try {
|
||||||
|
DB::table('artwork_awards')->insertOrIgnore([$row]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$stats['errors']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$skippedTotal = $stats['skipped_score']
|
||||||
|
+ $stats['skipped_artwork']
|
||||||
|
+ $stats['skipped_user']
|
||||||
|
+ $stats['skipped_duplicate'];
|
||||||
|
|
||||||
|
$bar->setMessage((string) $stats['imported'], 'imported');
|
||||||
|
$bar->setMessage((string) $skippedTotal, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Recalculate stats for every affected artwork
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
if (! $dryRun && ! $skipStats && ! empty($affectedArtworkIds)) {
|
||||||
|
$artworkCount = count($affectedArtworkIds);
|
||||||
|
$this->info("Recalculating award stats for {$artworkCount} artworks…");
|
||||||
|
|
||||||
|
$statsBar = $this->output->createProgressBar($artworkCount);
|
||||||
|
$statsBar->start();
|
||||||
|
|
||||||
|
foreach (array_keys($affectedArtworkIds) as $artworkId) {
|
||||||
|
try {
|
||||||
|
$service->recalcStats($artworkId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->warn("Stats recalc failed for artwork #{$artworkId}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
$statsBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$statsBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Summary
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
$this->table(
|
||||||
|
['Result', 'Count'],
|
||||||
|
[
|
||||||
|
['Imported (new rows)', $stats['imported']],
|
||||||
|
['Forced updates', $stats['updated_force']],
|
||||||
|
['Skipped – bad score', $stats['skipped_score']],
|
||||||
|
['Skipped – artwork gone', $stats['skipped_artwork']],
|
||||||
|
['Skipped – user gone', $stats['skipped_user']],
|
||||||
|
['Skipped – duplicate', $stats['skipped_duplicate']],
|
||||||
|
['Errors', $stats['errors']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
|
||||||
|
} else {
|
||||||
|
$this->info('Migration complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
266
app/Console/Commands/ImportLegacyComments.php
Normal file
266
app/Console/Commands/ImportLegacyComments.php
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates legacy `artworks_comments` (projekti_old_skinbase) into `artwork_comments`.
|
||||||
|
*
|
||||||
|
* Column mapping:
|
||||||
|
* legacy.comment_id → artwork_comments.legacy_id (idempotency key)
|
||||||
|
* legacy.artwork_id → artwork_comments.artwork_id
|
||||||
|
* legacy.user_id → artwork_comments.user_id
|
||||||
|
* legacy.description → artwork_comments.content
|
||||||
|
* legacy.date + .time → artwork_comments.created_at / updated_at
|
||||||
|
*
|
||||||
|
* Ignored legacy columns: owner, author (username strings), owner_user_id
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan comments:import-legacy
|
||||||
|
* php artisan comments:import-legacy --dry-run
|
||||||
|
* php artisan comments:import-legacy --chunk=1000
|
||||||
|
* php artisan comments:import-legacy --allow-guest-user=0 (import rows where user_id maps to 0 / not found, assigning a fallback user_id)
|
||||||
|
*/
|
||||||
|
class ImportLegacyComments extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'comments:import-legacy
|
||||||
|
{--dry-run : Preview only — no writes to DB}
|
||||||
|
{--chunk=500 : Rows to process per batch}
|
||||||
|
{--skip-empty : Skip comments with empty/whitespace-only content}';
|
||||||
|
|
||||||
|
protected $description = 'Import legacy artworks_comments into artwork_comments';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$skipEmpty = (bool) $this->option('skip-empty');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('[DRY-RUN] No data will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify legacy connection
|
||||||
|
try {
|
||||||
|
DB::connection('legacy')->getPdo();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('artworks_comments')) {
|
||||||
|
$this->error('Legacy table `artworks_comments` not found.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! DB::getSchemaBuilder()->hasColumn('artwork_comments', 'legacy_id')) {
|
||||||
|
$this->error('Column `legacy_id` missing from `artwork_comments`. Run: php artisan migrate');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-load valid artwork IDs and user IDs from new DB for O(1) lookup
|
||||||
|
$this->info('Loading new-DB artwork and user ID sets…');
|
||||||
|
|
||||||
|
$validArtworkIds = DB::table('artworks')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->pluck('id')
|
||||||
|
->flip()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$validUserIds = DB::table('users')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->pluck('id')
|
||||||
|
->flip()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Found %d artworks and %d users in new DB.',
|
||||||
|
count($validArtworkIds),
|
||||||
|
count($validUserIds)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Already-imported legacy IDs (to resume safely)
|
||||||
|
$this->info('Loading already-imported legacy_ids…');
|
||||||
|
$alreadyImported = DB::table('artwork_comments')
|
||||||
|
->whereNotNull('legacy_id')
|
||||||
|
->pluck('legacy_id')
|
||||||
|
->flip()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->info(sprintf('%d comments already imported (will be skipped).', count($alreadyImported)));
|
||||||
|
|
||||||
|
$total = DB::connection('legacy')->table('artworks_comments')->count();
|
||||||
|
$this->info("Legacy rows to process: {$total}");
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
$this->warn('No legacy rows found. Nothing to do.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'imported' => 0,
|
||||||
|
'skipped_duplicate' => 0,
|
||||||
|
'skipped_artwork' => 0,
|
||||||
|
'skipped_user' => 0,
|
||||||
|
'skipped_empty' => 0,
|
||||||
|
'errors' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
|
||||||
|
$bar->setMessage('0', 'imported');
|
||||||
|
$bar->setMessage('0', 'skipped');
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
DB::connection('legacy')
|
||||||
|
->table('artworks_comments')
|
||||||
|
->orderBy('comment_id')
|
||||||
|
->chunk($chunk, function ($rows) use (
|
||||||
|
&$stats,
|
||||||
|
&$alreadyImported,
|
||||||
|
$validArtworkIds,
|
||||||
|
$validUserIds,
|
||||||
|
$dryRun,
|
||||||
|
$skipEmpty,
|
||||||
|
$bar
|
||||||
|
) {
|
||||||
|
$inserts = [];
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$legacyId = (int) $row->comment_id;
|
||||||
|
$artworkId = (int) $row->artwork_id;
|
||||||
|
$userId = (int) $row->user_id;
|
||||||
|
$content = trim((string) ($row->description ?? ''));
|
||||||
|
|
||||||
|
// --- Already imported ---
|
||||||
|
if (isset($alreadyImported[$legacyId])) {
|
||||||
|
$stats['skipped_duplicate']++;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Content ---
|
||||||
|
if ($skipEmpty && $content === '') {
|
||||||
|
$stats['skipped_empty']++;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace empty content with a placeholder so NOT NULL is satisfied
|
||||||
|
if ($content === '') {
|
||||||
|
$content = '[no content]';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Artwork must exist ---
|
||||||
|
if (! isset($validArtworkIds[$artworkId])) {
|
||||||
|
$stats['skipped_artwork']++;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User must exist ---
|
||||||
|
if (! isset($validUserIds[$userId])) {
|
||||||
|
$stats['skipped_user']++;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Build timestamp from separate date + time columns ---
|
||||||
|
$createdAt = $this->buildTimestamp($row->date, $row->time, $now);
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$inserts[] = [
|
||||||
|
'legacy_id' => $legacyId,
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'content' => $content,
|
||||||
|
'is_approved' => 1,
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
'updated_at' => $createdAt,
|
||||||
|
'deleted_at' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$alreadyImported[$legacyId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['imported']++;
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun && ! empty($inserts)) {
|
||||||
|
try {
|
||||||
|
DB::table('artwork_comments')->insert($inserts);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Fallback: row-by-row with ignore on unique violations
|
||||||
|
foreach ($inserts as $row) {
|
||||||
|
try {
|
||||||
|
DB::table('artwork_comments')->insertOrIgnore([$row]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$stats['errors']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$skippedTotal = $stats['skipped_duplicate']
|
||||||
|
+ $stats['skipped_artwork']
|
||||||
|
+ $stats['skipped_user']
|
||||||
|
+ $stats['skipped_empty'];
|
||||||
|
|
||||||
|
$bar->setMessage((string) $stats['imported'], 'imported');
|
||||||
|
$bar->setMessage((string) $skippedTotal, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Summary
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
$this->table(
|
||||||
|
['Result', 'Count'],
|
||||||
|
[
|
||||||
|
['Imported', $stats['imported']],
|
||||||
|
['Skipped – already imported', $stats['skipped_duplicate']],
|
||||||
|
['Skipped – artwork gone', $stats['skipped_artwork']],
|
||||||
|
['Skipped – user gone', $stats['skipped_user']],
|
||||||
|
['Skipped – empty content', $stats['skipped_empty']],
|
||||||
|
['Errors', $stats['errors']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
|
||||||
|
} else {
|
||||||
|
$this->info('Migration complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine a legacy `date` (DATE) and `time` (TIME) column into a single datetime string.
|
||||||
|
* Falls back to $fallback when both are null.
|
||||||
|
*/
|
||||||
|
private function buildTimestamp(mixed $date, mixed $time, \Illuminate\Support\Carbon $fallback): string
|
||||||
|
{
|
||||||
|
if (! $date) {
|
||||||
|
return $fallback->toDateTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
$datePart = substr((string) $date, 0, 10); // '2000-09-13'
|
||||||
|
$timePart = $time ? substr((string) $time, 0, 8) : '00:00:00'; // '09:34:27'
|
||||||
|
|
||||||
|
// Sanity-check: MySQL TIME can be negative or > 24h for intervals — clamp to midnight
|
||||||
|
if (! preg_match('/^\d{2}:\d{2}:\d{2}$/', $timePart) || $timePart < '00:00:00') {
|
||||||
|
$timePart = '00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $datePart . ' ' . $timePart;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,43 +2,79 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Support\UsernamePolicy;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ImportLegacyUsers extends Command
|
class ImportLegacyUsers extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users}';
|
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--restore-temp-usernames : Restore legacy usernames for existing users still using tmpu12345-style placeholders} {--dry-run : Preview which users would be skipped/deleted without making changes}';
|
||||||
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
|
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
|
||||||
|
|
||||||
protected array $usedUsernames = [];
|
protected string $migrationLogPath;
|
||||||
protected array $usedEmails = [];
|
/** @var array<int,true> Legacy user IDs that qualify for import */
|
||||||
|
protected array $activeUserIds = [];
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
|
$this->migrationLogPath = (string) storage_path('logs/username_migration.log');
|
||||||
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
|
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
|
||||||
|
|
||||||
$chunk = (int) $this->option('chunk');
|
// Build the set of legacy user IDs that have any meaningful activity.
|
||||||
$imported = 0;
|
// Users outside this set will be skipped (or deleted from the new DB if already imported).
|
||||||
$skipped = 0;
|
$this->activeUserIds = $this->buildActiveUserIds();
|
||||||
|
$this->info('Active legacy users (uploads / comments / forum): ' . count($this->activeUserIds));
|
||||||
|
|
||||||
if (! DB::getPdo()) {
|
$chunk = (int) $this->option('chunk');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$imported = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$purged = 0;
|
||||||
|
|
||||||
|
if (! DB::connection('legacy')->getPdo()) {
|
||||||
$this->error('Legacy DB connection "legacy" is not configured or reachable.');
|
$this->error('Legacy DB connection "legacy" is not configured or reachable.');
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::table('users')
|
DB::connection('legacy')->table('users')
|
||||||
->chunkById($chunk, function ($rows) use (&$imported, &$skipped) {
|
->chunkById($chunk, function ($rows) use (&$imported, &$skipped, &$purged, $dryRun) {
|
||||||
$ids = $rows->pluck('user_id')->all();
|
$ids = $rows->pluck('user_id')->all();
|
||||||
$stats = DB::table('users_statistics')
|
$stats = DB::connection('legacy')->table('users_statistics')
|
||||||
->whereIn('user_id', $ids)
|
->whereIn('user_id', $ids)
|
||||||
->get()
|
->get()
|
||||||
->keyBy('user_id');
|
->keyBy('user_id');
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
$legacyId = (int) $row->user_id;
|
||||||
|
|
||||||
|
// ── Inactive user: no uploads, no comments, no forum activity ──
|
||||||
|
if (! isset($this->activeUserIds[$legacyId])) {
|
||||||
|
// If already imported into the new DB, purge it.
|
||||||
|
$existsInNew = DB::table('users')->where('id', $legacyId)->exists();
|
||||||
|
if ($existsInNew) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn("[dry] Would DELETE inactive user_id={$legacyId} from new DB");
|
||||||
|
} else {
|
||||||
|
$this->purgeNewUser($legacyId);
|
||||||
|
$this->warn("[purge] Deleted inactive user_id={$legacyId} from new DB");
|
||||||
|
$purged++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->line("[skip] user_id={$legacyId} no activity — skipping");
|
||||||
|
}
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line("[dry] Would import user_id={$legacyId}");
|
||||||
|
$imported++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->importRow($row, $stats[$row->user_id] ?? null);
|
$this->importRow($row, $stats[$row->user_id] ?? null);
|
||||||
$imported++;
|
$imported++;
|
||||||
@@ -49,17 +85,76 @@ class ImportLegacyUsers extends Command
|
|||||||
}
|
}
|
||||||
}, 'user_id');
|
}, 'user_id');
|
||||||
|
|
||||||
$this->info("Imported: {$imported}, Skipped: {$skipped}");
|
$this->info("Imported: {$imported}, Skipped: {$skipped}, Purged: {$purged}");
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a lookup array of legacy user IDs that qualify for import:
|
||||||
|
* — uploaded at least one artwork (users_statistics.uploads > 0)
|
||||||
|
* — posted at least one artwork comment (artworks_comments.user_id)
|
||||||
|
* — created or posted to a forum thread (forum_topics / forum_posts)
|
||||||
|
*
|
||||||
|
* @return array<int,true>
|
||||||
|
*/
|
||||||
|
protected function buildActiveUserIds(): array
|
||||||
|
{
|
||||||
|
$rows = DB::connection('legacy')->select("
|
||||||
|
SELECT DISTINCT user_id FROM users_statistics WHERE uploads > 0
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT user_id FROM artworks_comments WHERE user_id > 0
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT user_id FROM forum_posts WHERE user_id > 0
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT user_id FROM forum_topics WHERE user_id > 0
|
||||||
|
");
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$map[(int) $r->user_id] = true;
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all new-DB records for a given legacy user ID.
|
||||||
|
* Covers: users, user_profiles, user_statistics, username_redirects.
|
||||||
|
*/
|
||||||
|
protected function purgeNewUser(int $userId): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($userId) {
|
||||||
|
DB::table('username_redirects')->where('user_id', $userId)->delete();
|
||||||
|
DB::table('user_statistics')->where('user_id', $userId)->delete();
|
||||||
|
DB::table('user_profiles')->where('user_id', $userId)->delete();
|
||||||
|
DB::table('users')->where('id', $userId)->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected function importRow($row, $statRow = null): void
|
protected function importRow($row, $statRow = null): void
|
||||||
{
|
{
|
||||||
$legacyId = (int) $row->user_id;
|
$legacyId = (int) $row->user_id;
|
||||||
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
|
|
||||||
$username = $this->uniqueUsername($baseUsername);
|
|
||||||
|
|
||||||
$email = $this->prepareEmail($row->email ?? null, $username);
|
// Use legacy username as-is by default. Placeholder tmp usernames can be
|
||||||
|
// restored explicitly with --restore-temp-usernames using safe uniqueness rules.
|
||||||
|
$existingUser = DB::table('users')
|
||||||
|
->select(['id', 'username'])
|
||||||
|
->where('id', $legacyId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$username = $this->resolveImportUsername($row, $legacyId, $existingUser?->username ?? null);
|
||||||
|
|
||||||
|
$normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
||||||
|
if ($normalizedLegacy !== $username) {
|
||||||
|
@file_put_contents(
|
||||||
|
$this->migrationLogPath,
|
||||||
|
sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), $legacyId, $normalizedLegacy, $username),
|
||||||
|
FILE_APPEND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the real legacy email; only synthesise a placeholder when missing.
|
||||||
|
$rawEmail = $row->email ? strtolower(trim($row->email)) : null;
|
||||||
|
$email = $rawEmail ?: ($this->sanitizeEmailLocal($username) . '@users.skinbase.org');
|
||||||
|
|
||||||
$legacyPassword = $row->password2 ?: $row->password ?: null;
|
$legacyPassword = $row->password2 ?: $row->password ?: null;
|
||||||
|
|
||||||
@@ -84,59 +179,126 @@ class ImportLegacyUsers extends Command
|
|||||||
|
|
||||||
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
||||||
$now = now();
|
$now = now();
|
||||||
|
$existingUser = DB::table('users')
|
||||||
|
->select(['id', 'username'])
|
||||||
|
->where('id', $legacyId)
|
||||||
|
->first();
|
||||||
|
$alreadyExists = $existingUser !== null;
|
||||||
|
$previousUsername = (string) ($existingUser?->username ?? '');
|
||||||
|
|
||||||
DB::table('users')->insert([
|
// All fields synced from legacy on every run
|
||||||
'id' => $legacyId,
|
$sharedFields = [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'name' => $row->real_name ?: $username,
|
'username_changed_at' => $now,
|
||||||
'email' => $email,
|
'name' => $row->real_name ?: $username,
|
||||||
'password' => $passwordHash,
|
'email' => $email,
|
||||||
'is_active' => (int) ($row->active ?? 1) === 1,
|
'is_active' => (int) ($row->active ?? 1) === 1,
|
||||||
'needs_password_reset' => true,
|
'needs_password_reset' => true,
|
||||||
'role' => 'user',
|
'role' => 'user',
|
||||||
'legacy_password_algo' => null,
|
'legacy_password_algo' => null,
|
||||||
'last_visit_at' => $row->LastVisit ?: null,
|
'last_visit_at' => $row->LastVisit ?: null,
|
||||||
'created_at' => $row->joinDate ?: $now,
|
'updated_at' => $now,
|
||||||
'updated_at' => $now,
|
];
|
||||||
]);
|
|
||||||
|
|
||||||
DB::table('user_profiles')->insert([
|
if ($alreadyExists) {
|
||||||
'user_id' => $legacyId,
|
// Sync all fields from legacy — password is never overwritten on re-runs
|
||||||
'bio' => $row->about_me ?: $row->description ?: null,
|
// (unless --force-reset-all was passed, in which case the caller handles it
|
||||||
'avatar' => $row->picture ?: null,
|
// separately outside this transaction).
|
||||||
'cover_image' => $row->cover_art ?: null,
|
DB::table('users')->where('id', $legacyId)->update($sharedFields);
|
||||||
'country' => $row->country ?: null,
|
} else {
|
||||||
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
DB::table('users')->insert(array_merge($sharedFields, [
|
||||||
'language' => $row->lang ?: null,
|
'id' => $legacyId,
|
||||||
'birthdate' => $row->birth ?: null,
|
'password' => $passwordHash,
|
||||||
'gender' => $row->gender ?: 'X',
|
'created_at' => $row->joinDate ?: $now,
|
||||||
'website' => $row->web ?: null,
|
]));
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!empty($row->web)) {
|
|
||||||
DB::table('user_social_links')->insert([
|
|
||||||
'user_id' => $legacyId,
|
|
||||||
'platform' => 'website',
|
|
||||||
'url' => $row->web,
|
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::table('user_statistics')->insert([
|
DB::table('user_profiles')->updateOrInsert(
|
||||||
'user_id' => $legacyId,
|
['user_id' => $legacyId],
|
||||||
'uploads' => $uploads,
|
[
|
||||||
'downloads' => $downloads,
|
'about' => $row->about_me ?: $row->description ?: null,
|
||||||
'pageviews' => $pageviews,
|
'avatar_legacy' => $row->picture ?: null,
|
||||||
'awards' => $awards,
|
'cover_image' => $row->cover_art ?: null,
|
||||||
'created_at' => $now,
|
'country' => $row->country ?: null,
|
||||||
'updated_at' => $now,
|
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
||||||
]);
|
'language' => $row->lang ?: null,
|
||||||
|
'birthdate' => $row->birth ?: null,
|
||||||
|
'gender' => $this->normalizeLegacyGender($row->gender ?? null),
|
||||||
|
'website' => $row->web ?: null,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`.
|
||||||
|
|
||||||
|
DB::table('user_statistics')->updateOrInsert(
|
||||||
|
['user_id' => $legacyId],
|
||||||
|
[
|
||||||
|
'uploads_count' => $uploads,
|
||||||
|
'downloads_received_count' => $downloads,
|
||||||
|
'artwork_views_received_count' => $pageviews,
|
||||||
|
'awards_received_count' => $awards,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Schema::hasTable('username_redirects')) {
|
||||||
|
$old = $this->usernameRedirectKey((string) ($row->uname ?? ''));
|
||||||
|
if ($old !== '' && $old !== $username) {
|
||||||
|
DB::table('username_redirects')->updateOrInsert(
|
||||||
|
['old_username' => $old],
|
||||||
|
[
|
||||||
|
'new_username' => $username,
|
||||||
|
'user_id' => $legacyId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->shouldRestoreTemporaryUsername($previousUsername) && $previousUsername !== $username) {
|
||||||
|
DB::table('username_redirects')->updateOrInsert(
|
||||||
|
['old_username' => $this->usernameRedirectKey($previousUsername)],
|
||||||
|
[
|
||||||
|
'new_username' => $username,
|
||||||
|
'user_id' => $legacyId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function resolveImportUsername(object $row, int $legacyId, ?string $existingUsername = null): string
|
||||||
|
{
|
||||||
|
$legacyUsername = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
|
||||||
|
|
||||||
|
if (! $this->option('restore-temp-usernames')) {
|
||||||
|
return $legacyUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingUsername === null || $existingUsername === '') {
|
||||||
|
return $legacyUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->shouldRestoreTemporaryUsername($existingUsername)) {
|
||||||
|
return $existingUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
return UsernamePolicy::uniqueCandidate((string) ($row->uname ?: ('user' . $legacyId)), $legacyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function shouldRestoreTemporaryUsername(?string $username): bool
|
||||||
|
{
|
||||||
|
if (! is_string($username) || trim($username) === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/^tmpu\d+$/i', trim($username)) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure statistic values are safe for unsigned DB columns.
|
* Ensure statistic values are safe for unsigned DB columns.
|
||||||
*/
|
*/
|
||||||
@@ -151,45 +313,25 @@ class ImportLegacyUsers extends Command
|
|||||||
|
|
||||||
protected function sanitizeUsername(string $username): string
|
protected function sanitizeUsername(string $username): string
|
||||||
{
|
{
|
||||||
$username = strtolower(trim($username));
|
return UsernamePolicy::sanitizeLegacy($username);
|
||||||
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
|
|
||||||
return trim($username, '.-') ?: 'user';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function uniqueUsername(string $base): string
|
protected function usernameRedirectKey(?string $username): string
|
||||||
{
|
{
|
||||||
$name = $base;
|
$value = $this->sanitizeUsername((string) ($username ?? ''));
|
||||||
$i = 1;
|
|
||||||
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
|
return $value === 'user' && trim((string) ($username ?? '')) === '' ? '' : $value;
|
||||||
$name = $base . '-' . $i;
|
|
||||||
$i++;
|
|
||||||
}
|
|
||||||
$this->usedUsernames[$name] = $name;
|
|
||||||
return $name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function prepareEmail(?string $legacyEmail, string $username): string
|
protected function normalizeLegacyGender(mixed $value): ?string
|
||||||
{
|
{
|
||||||
$legacyEmail = $legacyEmail ? strtolower(trim($legacyEmail)) : null;
|
$normalized = strtoupper(trim((string) ($value ?? '')));
|
||||||
$baseLocal = $this->sanitizeEmailLocal($username);
|
|
||||||
$domain = 'users.skinbase.org';
|
|
||||||
|
|
||||||
$email = $legacyEmail ?: ($baseLocal . '@' . $domain);
|
return match ($normalized) {
|
||||||
$email = $this->uniqueEmail($email, $baseLocal, $domain);
|
'M', 'MALE', 'MAN', 'BOY' => 'M',
|
||||||
return $email;
|
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
|
||||||
}
|
default => null,
|
||||||
|
};
|
||||||
protected function uniqueEmail(string $email, string $baseLocal, string $domain): string
|
|
||||||
{
|
|
||||||
$i = 1;
|
|
||||||
$local = explode('@', $email)[0];
|
|
||||||
$current = $email;
|
|
||||||
while (isset($this->usedEmails[$current]) || DB::table('users')->where('email', $current)->exists()) {
|
|
||||||
$current = $local . $i . '@' . $domain;
|
|
||||||
$i++;
|
|
||||||
}
|
|
||||||
$this->usedEmails[$current] = $current;
|
|
||||||
return $current;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function sanitizeEmailLocal(string $value): string
|
protected function sanitizeEmailLocal(string $value): string
|
||||||
|
|||||||
184
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal file
184
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||||
|
use App\Services\Vision\VectorGatewayClient;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
final class IndexArtworkVectorsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:vectors-index
|
||||||
|
{--start-id=0 : Start from this artwork id (inclusive)}
|
||||||
|
{--after-id=0 : Resume after this artwork id}
|
||||||
|
{--batch=100 : Batch size per iteration}
|
||||||
|
{--limit=0 : Maximum artworks to process in this run}
|
||||||
|
{--public-only : Index only public, approved, published artworks}
|
||||||
|
{--dry-run : Preview requests without sending them}';
|
||||||
|
|
||||||
|
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
|
||||||
|
|
||||||
|
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
if (! $dryRun && ! $client->isConfigured()) {
|
||||||
|
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startId = max(0, (int) $this->option('start-id'));
|
||||||
|
$afterId = max(0, (int) $this->option('after-id'));
|
||||||
|
$batch = max(1, min((int) $this->option('batch'), 1000));
|
||||||
|
$limit = max(0, (int) $this->option('limit'));
|
||||||
|
$publicOnly = (bool) $this->option('public-only');
|
||||||
|
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$indexed = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$lastId = $afterId;
|
||||||
|
|
||||||
|
if ($startId > 0 && $afterId > 0) {
|
||||||
|
$this->warn(sprintf(
|
||||||
|
'Both --start-id=%d and --after-id=%d were provided. Using --start-id and ignoring --after-id.',
|
||||||
|
$startId,
|
||||||
|
$afterId
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s public_only=%s dry_run=%s',
|
||||||
|
$startId,
|
||||||
|
$afterId,
|
||||||
|
$nextId,
|
||||||
|
$batch,
|
||||||
|
$limit > 0 ? (string) $limit : 'all',
|
||||||
|
$publicOnly ? 'yes' : 'no',
|
||||||
|
$dryRun ? 'yes' : 'no'
|
||||||
|
));
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$remaining = $limit > 0 ? max(0, $limit - $processed) : $batch;
|
||||||
|
if ($limit > 0 && $remaining === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$take = $limit > 0 ? min($batch, $remaining) : $batch;
|
||||||
|
|
||||||
|
$query = Artwork::query()
|
||||||
|
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||||
|
->where('id', '>=', $nextId)
|
||||||
|
->whereNotNull('hash')
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($take);
|
||||||
|
|
||||||
|
if ($publicOnly) {
|
||||||
|
$query->public()->published();
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworks = $query->get();
|
||||||
|
if ($artworks->isEmpty()) {
|
||||||
|
$this->line('No more artworks matched the current query window.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'Fetched batch: count=%d first_id=%d last_id=%d',
|
||||||
|
$artworks->count(),
|
||||||
|
(int) $artworks->first()->id,
|
||||||
|
(int) $artworks->last()->id
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($artworks as $artwork) {
|
||||||
|
$processed++;
|
||||||
|
$lastId = (int) $artwork->id;
|
||||||
|
$nextId = $lastId + 1;
|
||||||
|
|
||||||
|
$url = $imageUrl->fromArtwork($artwork);
|
||||||
|
if ($url === null) {
|
||||||
|
$skipped++;
|
||||||
|
$this->warn("Skipped artwork {$artwork->id}: no vision image URL could be generated.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = $this->metadataForArtwork($artwork);
|
||||||
|
$this->line(sprintf(
|
||||||
|
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
|
||||||
|
(int) $artwork->id,
|
||||||
|
(string) ($artwork->hash ?? ''),
|
||||||
|
(string) ($artwork->thumb_ext ?? ''),
|
||||||
|
$url,
|
||||||
|
$this->json($metadata)
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$indexed++;
|
||||||
|
$this->line(sprintf(
|
||||||
|
'[dry] artwork=%d indexed=%d/%d',
|
||||||
|
(int) $artwork->id,
|
||||||
|
$indexed,
|
||||||
|
$processed
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client->upsertByUrl($url, (int) $artwork->id, $metadata);
|
||||||
|
$indexed++;
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
|
||||||
|
(int) $artwork->id,
|
||||||
|
$processed,
|
||||||
|
$indexed,
|
||||||
|
$skipped,
|
||||||
|
$failed
|
||||||
|
));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$this->warn("Failed artwork {$artwork->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}");
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $payload
|
||||||
|
*/
|
||||||
|
private function json(array $payload): string
|
||||||
|
{
|
||||||
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return is_string($json) ? $json : '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{content_type: string, category: string, user_id: string}
|
||||||
|
*/
|
||||||
|
private function metadataForArtwork(Artwork $artwork): array
|
||||||
|
{
|
||||||
|
$category = $this->primaryCategory($artwork);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content_type' => (string) ($category?->contentType?->name ?? ''),
|
||||||
|
'category' => (string) ($category?->name ?? ''),
|
||||||
|
'user_id' => (string) ($artwork->user_id ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function primaryCategory(Artwork $artwork): ?Category
|
||||||
|
{
|
||||||
|
/** @var Category|null $category */
|
||||||
|
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||||
|
|
||||||
|
return $category;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect hourly metric snapshots for artworks.
|
||||||
|
*
|
||||||
|
* Runs on cron every hour. Inserts a row per artwork into
|
||||||
|
* artwork_metric_snapshots_hourly with the current totals.
|
||||||
|
* Deltas are computed by the heat recalculation command.
|
||||||
|
*
|
||||||
|
* Usage: php artisan nova:metrics-snapshot-hourly
|
||||||
|
* php artisan nova:metrics-snapshot-hourly --days=30 --chunk=500 --dry-run
|
||||||
|
*/
|
||||||
|
class MetricsSnapshotHourlyCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'nova:metrics-snapshot-hourly
|
||||||
|
{--days=60 : Only snapshot artworks created within this many days}
|
||||||
|
{--chunk=1000 : Chunk size for DB queries}
|
||||||
|
{--dry-run : Log what would be written without persisting}';
|
||||||
|
|
||||||
|
protected $description = 'Collect hourly metric snapshots for rising/heat calculation';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$chunk = (int) $this->option('chunk');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$bucketHour = now()->startOfHour();
|
||||||
|
|
||||||
|
$this->info("[nova:metrics-snapshot-hourly] bucket={$bucketHour->toDateTimeString()} days={$days} chunk={$chunk}" . ($dryRun ? ' (dry-run)' : ''));
|
||||||
|
|
||||||
|
$snapshotCount = 0;
|
||||||
|
$skipCount = 0;
|
||||||
|
|
||||||
|
// Query artworks eligible for snapshotting:
|
||||||
|
// - created within $days OR has a ranking_score above 0
|
||||||
|
// First collect eligible IDs, then process in chunks
|
||||||
|
$eligibleIds = DB::table('artworks')
|
||||||
|
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
|
||||||
|
->where(function ($q) use ($days) {
|
||||||
|
$q->where('artworks.created_at', '>=', now()->subDays($days))
|
||||||
|
->orWhere(function ($q2) {
|
||||||
|
$q2->whereNotNull('s.ranking_score')
|
||||||
|
->where('s.ranking_score', '>', 0);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->whereNull('artworks.deleted_at')
|
||||||
|
->where('artworks.is_approved', true)
|
||||||
|
->pluck('artworks.id');
|
||||||
|
|
||||||
|
if ($eligibleIds->isEmpty()) {
|
||||||
|
$this->info('No eligible artworks found.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($eligibleIds->chunk($chunk) as $chunkIds) {
|
||||||
|
$artworkIds = $chunkIds->values()->all();
|
||||||
|
|
||||||
|
$stats = DB::table('artwork_stats')
|
||||||
|
->whereIn('artwork_id', $artworkIds)
|
||||||
|
->get()
|
||||||
|
->keyBy('artwork_id');
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($artworkIds as $artworkId) {
|
||||||
|
$stat = $stats->get($artworkId);
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'bucket_hour' => $bucketHour,
|
||||||
|
'views_count' => (int) ($stat?->views ?? 0),
|
||||||
|
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
||||||
|
'favourites_count' => (int) ($stat?->favorites ?? 0),
|
||||||
|
'comments_count' => (int) ($stat?->comments_count ?? 0),
|
||||||
|
'shares_count' => (int) ($stat?->shares_count ?? 0),
|
||||||
|
'created_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$snapshotCount += count($rows);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($rows)) {
|
||||||
|
// Upsert: if (artwork_id, bucket_hour) already exists, update totals
|
||||||
|
DB::table('artwork_metric_snapshots_hourly')->upsert(
|
||||||
|
$rows,
|
||||||
|
['artwork_id', 'bucket_hour'],
|
||||||
|
['views_count', 'downloads_count', 'favourites_count', 'comments_count', 'shares_count']
|
||||||
|
);
|
||||||
|
|
||||||
|
$snapshotCount += count($rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Snapshots written: {$snapshotCount} | Skipped: {$skipCount}");
|
||||||
|
|
||||||
|
Log::info('[nova:metrics-snapshot-hourly] completed', [
|
||||||
|
'bucket' => $bucketHour->toDateTimeString(),
|
||||||
|
'written' => $snapshotCount,
|
||||||
|
'skipped' => $skipCount,
|
||||||
|
'dry_run' => $dryRun,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
325
app/Console/Commands/MigrateFavourites.php
Normal file
325
app/Console/Commands/MigrateFavourites.php
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* php artisan skinbase:migrate-favourites
|
||||||
|
*
|
||||||
|
* Migrates rows from the legacy `favourites` table (projekti_old_skinbase)
|
||||||
|
* into the new `artwork_favourites` table on the default connection.
|
||||||
|
*
|
||||||
|
* Skipped rows (logged as warnings):
|
||||||
|
* - artwork_id not found in new artworks table
|
||||||
|
* - user_id not found in new OR legacy users table (unless --import-missing-users)
|
||||||
|
* - row already imported (duplicate legacy_id)
|
||||||
|
* - would create a duplicate (user_id, artwork_id) pair
|
||||||
|
*
|
||||||
|
* Dropped legacy columns (not migrated):
|
||||||
|
* - user_type — membership tier, not relevant to the relationship
|
||||||
|
* - author_id — always derivable via artworks.user_id
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --dry-run Preview without writing
|
||||||
|
* --chunk=500 Rows per batch
|
||||||
|
* --start-id=0 Resume from this favourite_id
|
||||||
|
* --limit=0 Stop after N inserts (0 = no limit)
|
||||||
|
* --import-missing-users Auto-create a stub user from legacy data when the
|
||||||
|
* user is missing from the new DB (needs_password_reset=true)
|
||||||
|
* --legacy-connection Override legacy DB connection name (default: legacy)
|
||||||
|
* --legacy-table Override legacy favourites table name (default: favourites)
|
||||||
|
* --legacy-users-table Override legacy users table name (default: users)
|
||||||
|
*/
|
||||||
|
class MigrateFavourites extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:migrate-favourites
|
||||||
|
{--dry-run : Preview changes without writing to the database}
|
||||||
|
{--chunk=500 : Number of rows to process per batch}
|
||||||
|
{--start-id=0 : Resume processing from this favourite_id}
|
||||||
|
{--limit=0 : Stop after inserting this many rows (0 = unlimited)}
|
||||||
|
{--import-missing-users : Auto-create stub users from legacy data when missing from new DB}
|
||||||
|
{--legacy-connection=legacy : Name of the legacy DB connection}
|
||||||
|
{--legacy-table=favourites : Name of the legacy favourites table}
|
||||||
|
{--legacy-users-table=users : Name of the legacy users table}';
|
||||||
|
|
||||||
|
protected $description = 'Migrate legacy favourites into artwork_favourites.';
|
||||||
|
|
||||||
|
// ── Counters ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private int $inserted = 0;
|
||||||
|
private int $skipped = 0;
|
||||||
|
private int $total = 0;
|
||||||
|
private int $usersImported = 0;
|
||||||
|
|
||||||
|
// ── Runtime config (set in handle()) ─────────────────────────────────────
|
||||||
|
|
||||||
|
private bool $importMissingUsers = false;
|
||||||
|
private string $legacyConn = 'legacy';
|
||||||
|
private string $legacyUsersTable = 'users';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$startId = max(0, (int) $this->option('start-id'));
|
||||||
|
$limit = max(0, (int) $this->option('limit'));
|
||||||
|
|
||||||
|
$this->importMissingUsers = (bool) $this->option('import-missing-users');
|
||||||
|
$this->legacyConn = (string) $this->option('legacy-connection');
|
||||||
|
$this->legacyUsersTable = (string) $this->option('legacy-users-table');
|
||||||
|
$legacyTable = (string) $this->option('legacy-table');
|
||||||
|
|
||||||
|
$this->info("Migrating <comment>{$this->legacyConn}.{$legacyTable}</comment> → <info>artwork_favourites</info>");
|
||||||
|
|
||||||
|
if ($this->importMissingUsers) {
|
||||||
|
$this->warn('--import-missing-users: stub users will be created with needs_password_reset=true.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY-RUN mode — no rows will be written.');
|
||||||
|
}
|
||||||
|
if ($startId > 0) {
|
||||||
|
$this->line("Resuming from favourite_id >= {$startId}");
|
||||||
|
}
|
||||||
|
if ($limit > 0) {
|
||||||
|
$this->line("Will stop after {$limit} inserts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = DB::connection($this->legacyConn)
|
||||||
|
->table($legacyTable)
|
||||||
|
->orderBy('favourite_id');
|
||||||
|
|
||||||
|
if ($startId > 0) {
|
||||||
|
$query->where('favourite_id', '>=', $startId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->chunkById(
|
||||||
|
$chunk,
|
||||||
|
function ($rows) use ($dryRun, $limit): bool {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$this->total++;
|
||||||
|
|
||||||
|
if ($limit > 0 && $this->inserted >= $limit) {
|
||||||
|
return false; // stop chunking
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->processRow($row, $dryRun) === false) {
|
||||||
|
$this->skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
'favourite_id',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Done. %d scanned, %d %s, %d skipped%s.',
|
||||||
|
$this->total,
|
||||||
|
$this->inserted,
|
||||||
|
$dryRun ? 'would be inserted' : 'inserted',
|
||||||
|
$this->skipped,
|
||||||
|
$this->usersImported > 0
|
||||||
|
? ", {$this->usersImported} stub users " . ($dryRun ? 'would be ' : '') . 'created'
|
||||||
|
: '',
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row processing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single legacy row. Returns true on success, false when skipped.
|
||||||
|
*/
|
||||||
|
private function processRow(object $row, bool $dryRun): bool
|
||||||
|
{
|
||||||
|
$legacyId = (int) ($row->favourite_id ?? 0);
|
||||||
|
$artworkId = (int) ($row->artwork_id ?? 0);
|
||||||
|
$userId = (int) ($row->user_id ?? 0);
|
||||||
|
$datum = $row->datum ?? null;
|
||||||
|
|
||||||
|
// ── Validate IDs ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($artworkId <= 0 || $userId <= 0) {
|
||||||
|
$this->skip($legacyId, "invalid artwork_id={$artworkId} or user_id={$userId}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! DB::table('artworks')->where('id', $artworkId)->exists()) {
|
||||||
|
$this->skip($legacyId, "artwork #{$artworkId} not found in new DB");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! DB::table('users')->where('id', $userId)->exists()) {
|
||||||
|
if ($this->importMissingUsers) {
|
||||||
|
if (! $this->importUserStub($userId, $dryRun)) {
|
||||||
|
$this->skip($legacyId, "user #{$userId} not found in legacy DB either — skipped");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->skip($legacyId, "user #{$userId} not found in new DB (use --import-missing-users to auto-create)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Idempotency guards ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (DB::table('artwork_favourites')->where('legacy_id', $legacyId)->exists()) {
|
||||||
|
// Already imported — silently skip (not counted as "skipped" error)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DB::table('artwork_favourites')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('artwork_id', $artworkId)
|
||||||
|
->exists()
|
||||||
|
) {
|
||||||
|
$this->skip($legacyId, "duplicate (user={$userId}, artwork={$artworkId}) already exists");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map timestamp ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$createdAt = $this->parseDate($datum);
|
||||||
|
|
||||||
|
// ── Insert ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
DB::table('artwork_favourites')->insert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'legacy_id' => $legacyId,
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
'updated_at' => $createdAt,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->inserted++;
|
||||||
|
|
||||||
|
if ($this->inserted % 500 === 0) {
|
||||||
|
$this->line(" {$this->inserted} inserted, {$this->skipped} skipped…");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up $userId in the legacy users table and create a stub record in
|
||||||
|
* the new users table preserving the same primary key.
|
||||||
|
*
|
||||||
|
* The stub has:
|
||||||
|
* - needs_password_reset = true (user must reset before logging in)
|
||||||
|
* - legacy_password_algo = 'legacy' (marks imported credential)
|
||||||
|
* - is_active determined from legacy `active` flag
|
||||||
|
* - email placeholder if original email is null or already taken
|
||||||
|
*
|
||||||
|
* @return bool true = stub created (or already existed), false = not in legacy DB
|
||||||
|
*/
|
||||||
|
private function importUserStub(int $userId, bool $dryRun): bool
|
||||||
|
{
|
||||||
|
// Already exists — nothing to do.
|
||||||
|
if (DB::table('users')->where('id', $userId)->exists()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacyUser = DB::connection($this->legacyConn)
|
||||||
|
->table($this->legacyUsersTable)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $legacyUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map fields ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$username = trim((string) ($legacyUser->uname ?? '')) ?: "user_{$userId}";
|
||||||
|
|
||||||
|
// Ensure username is unique in the new DB.
|
||||||
|
if (DB::table('users')->where('username', $username)->exists()) {
|
||||||
|
$username = $username . '_' . $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) ($legacyUser->real_name ?? '')) ?: $username;
|
||||||
|
$email = trim((string) ($legacyUser->email ?? ''));
|
||||||
|
|
||||||
|
// Resolve email: use placeholder when blank or already taken.
|
||||||
|
if ($email === '' || DB::table('users')->where('email', $email)->exists()) {
|
||||||
|
$email = "legacy_{$userId}@legacy.skinbase.org";
|
||||||
|
}
|
||||||
|
|
||||||
|
$isActive = ((int) ($legacyUser->active ?? 0)) === 1;
|
||||||
|
$createdAt = $this->parseDate($legacyUser->joinDate ?? null);
|
||||||
|
$lastVisit = $this->parseDate($legacyUser->LastVisit ?? null);
|
||||||
|
|
||||||
|
$stub = [
|
||||||
|
'id' => $userId,
|
||||||
|
'username' => $username,
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
'password' => bcrypt(Str::random(48)), // unusable random password
|
||||||
|
'needs_password_reset' => true,
|
||||||
|
'legacy_password_algo' => 'legacy',
|
||||||
|
'is_active' => $isActive,
|
||||||
|
'role' => 'user',
|
||||||
|
'last_visit_at' => $lastVisit !== $createdAt ? $lastVisit : null,
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
'updated_at' => $createdAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
$msg = "Stub user created: #{$userId} ({$username}, {$email})";
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(" [dry] {$msg}");
|
||||||
|
$this->usersImported++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Force explicit ID insert — MySQL respects it even with auto_increment.
|
||||||
|
DB::table('users')->insert($stub);
|
||||||
|
$this->usersImported++;
|
||||||
|
$this->line(" <info>{$msg}</info>");
|
||||||
|
Log::info("skinbase:migrate-favourites {$msg}");
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$err = "Failed to create stub user #{$userId}: {$e->getMessage()}";
|
||||||
|
$this->warn(" {$err}");
|
||||||
|
Log::error("skinbase:migrate-favourites {$err}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a legacy date value (DATE string / null / zero-date) to a
|
||||||
|
* full datetime string safe for MySQL.
|
||||||
|
*/
|
||||||
|
private function parseDate(mixed $value): string
|
||||||
|
{
|
||||||
|
if (empty($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
|
||||||
|
return Carbon::now()->toDateTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::parse((string) $value)->toDateTimeString();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return Carbon::now()->toDateTimeString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function skip(int $legacyId, string $reason): void
|
||||||
|
{
|
||||||
|
$msg = "SKIP favourite#{$legacyId}: {$reason}";
|
||||||
|
$this->warn(" {$msg}");
|
||||||
|
Log::warning("skinbase:migrate-favourites {$msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
351
app/Console/Commands/MigrateFollows.php
Normal file
351
app/Console/Commands/MigrateFollows.php
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Support\UsernamePolicy;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates legacy friends_list (from the legacy DB connection) into user_followers.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan skinbase:migrate-follows [--dry-run] [--chunk=1000] [--import-missing-users]
|
||||||
|
*
|
||||||
|
* Legacy table: friends_list
|
||||||
|
* user_id -> follower_id (the user who added the friend = someone who follows)
|
||||||
|
* friend_id -> user_id (the user being followed)
|
||||||
|
*
|
||||||
|
* With --import-missing-users: any user referenced in friends_list that does not
|
||||||
|
* exist in the new DB will be fetched from the legacy `users` table and created
|
||||||
|
* as a stub before the follow row is inserted.
|
||||||
|
*/
|
||||||
|
class MigrateFollows extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:migrate-follows
|
||||||
|
{--dry-run : Simulate without writing to the database}
|
||||||
|
{--chunk=1000 : Number of rows to process per batch}
|
||||||
|
{--import-missing-users : Import unknown users from legacy DB instead of skipping them}';
|
||||||
|
|
||||||
|
protected $description = 'Migrate legacy friends_list into user_followers';
|
||||||
|
|
||||||
|
/** Cache per-run: id => true (resolved) | null (not in legacy DB) | false (import error) */
|
||||||
|
private array $legacyUserCache = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$isDryRun = (bool) $this->option('dry-run');
|
||||||
|
$chunkSize = max(1, (int) $this->option('chunk'));
|
||||||
|
$importMissing = (bool) $this->option('import-missing-users');
|
||||||
|
|
||||||
|
$this->info($isDryRun
|
||||||
|
? '🔍 Dry-run mode – nothing will be written.'
|
||||||
|
: '🚀 Live mode – writing to user_followers.'
|
||||||
|
);
|
||||||
|
if ($importMissing) {
|
||||||
|
$this->info('👤 --import-missing-users: orphan users will be fetched from legacy DB.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$totalLegacy = DB::connection('legacy')->table('friends_list')->count();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Cannot read legacy friends_list: ' . $e->getMessage());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Total rows in legacy friends_list: {$totalLegacy}");
|
||||||
|
|
||||||
|
$validUserIds = DB::table('users')->pluck('id')->flip()->all();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'processed' => 0,
|
||||||
|
'inserted' => 0,
|
||||||
|
'duplicates' => 0,
|
||||||
|
'self_follows' => 0,
|
||||||
|
'invalid' => 0, // total orphan rows skipped
|
||||||
|
'invalid_zero_id' => 0, // follower_id or friend_id was 0
|
||||||
|
'invalid_not_in_new' => 0, // not in new DB (--import-missing-users not used)
|
||||||
|
'invalid_not_in_legacy' => 0, // not in new DB AND not in legacy DB
|
||||||
|
'invalid_import_error' => 0, // in legacy DB but stub import failed
|
||||||
|
'users_imported' => 0,
|
||||||
|
'errors' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$logPath = storage_path('logs/migrate_follows.log');
|
||||||
|
$logFile = fopen($logPath, 'a');
|
||||||
|
$this->logLine($logFile, '=== migrate-follows started at ' . now()->toISOString()
|
||||||
|
. " (dry_run={$isDryRun}, import_missing={$importMissing}) ===");
|
||||||
|
|
||||||
|
$chunkNum = 0;
|
||||||
|
$reportEvery = max(1, (int) ceil($totalLegacy / $chunkSize / 10));
|
||||||
|
|
||||||
|
DB::connection('legacy')
|
||||||
|
->table('friends_list')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunk($chunkSize, function ($rows) use (
|
||||||
|
$isDryRun,
|
||||||
|
$importMissing,
|
||||||
|
&$validUserIds,
|
||||||
|
&$stats,
|
||||||
|
&$chunkNum,
|
||||||
|
$reportEvery,
|
||||||
|
$totalLegacy,
|
||||||
|
$logFile
|
||||||
|
) {
|
||||||
|
$toInsert = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$stats['processed']++;
|
||||||
|
|
||||||
|
$followerId = (int) ($row->user_id ?? 0);
|
||||||
|
$followedId = (int) ($row->friend_id ?? 0);
|
||||||
|
$createdAt = $row->date_added ?? now();
|
||||||
|
|
||||||
|
if ($followerId === $followedId) {
|
||||||
|
$stats['self_follows']++;
|
||||||
|
$this->logLine($logFile, "SKIP self-follow: user_id={$followerId}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve any user_id that isn't in the new DB yet
|
||||||
|
$skipReasons = [];
|
||||||
|
$sides = ['follower' => $followerId, 'followed' => $followedId];
|
||||||
|
|
||||||
|
foreach ($sides as $role => $uid) {
|
||||||
|
if (isset($validUserIds[$uid])) {
|
||||||
|
continue; // already valid
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uid === 0) {
|
||||||
|
$skipReasons[] = "{$role}_id is 0/null";
|
||||||
|
$stats['invalid_zero_id']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $importMissing) {
|
||||||
|
$skipReasons[] = "{$role}={$uid} not in users table (use --import-missing-users to auto-import)";
|
||||||
|
$stats['invalid_not_in_new']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureLegacyUser returns: true = resolved, null = not in legacy, false = import error
|
||||||
|
$result = $this->ensureLegacyUser($uid, $isDryRun, $logFile);
|
||||||
|
if ($result === true) {
|
||||||
|
$validUserIds[$uid] = true;
|
||||||
|
$stats['users_imported']++;
|
||||||
|
} elseif ($result === null) {
|
||||||
|
$skipReasons[] = "{$role}={$uid} not found in legacy DB";
|
||||||
|
$stats['invalid_not_in_legacy']++;
|
||||||
|
} else {
|
||||||
|
$skipReasons[] = "{$role}={$uid} found in legacy DB but import failed";
|
||||||
|
$stats['invalid_import_error']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($validUserIds[$followerId]) || ! isset($validUserIds[$followedId])) {
|
||||||
|
$stats['invalid']++;
|
||||||
|
$reason = implode('; ', $skipReasons) ?: 'unknown';
|
||||||
|
$this->logLine($logFile, "SKIP orphan [row_id={$row->id}] follower={$followerId} followed={$followedId} — {$reason}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$toInsert[] = [
|
||||||
|
'follower_id' => $followerId,
|
||||||
|
'user_id' => $followedId,
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $isDryRun && ! empty($toInsert)) {
|
||||||
|
try {
|
||||||
|
$inserted = DB::table('user_followers')->insertOrIgnore($toInsert);
|
||||||
|
$stats['inserted'] += $inserted;
|
||||||
|
$stats['duplicates'] += count($toInsert) - $inserted;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$stats['errors']++;
|
||||||
|
$this->logLine($logFile, 'ERROR batch insert: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
} elseif ($isDryRun) {
|
||||||
|
$stats['inserted'] += count($toInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunkNum++;
|
||||||
|
if ($chunkNum % $reportEvery === 0 || $stats['processed'] >= $totalLegacy) {
|
||||||
|
$pct = $totalLegacy > 0 ? round($stats['processed'] / $totalLegacy * 100) : 100;
|
||||||
|
$this->line(" {$stats['processed']} / {$totalLegacy} rows ({$pct}%)"
|
||||||
|
. " inserted: {$stats['inserted']}"
|
||||||
|
. " imported: {$stats['users_imported']}"
|
||||||
|
. " skipped: " . ($stats['self_follows'] + $stats['invalid']));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if (! $isDryRun) {
|
||||||
|
$this->info('Backfilling user_statistics counters...');
|
||||||
|
$this->backfillCounters();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Metric', 'Count'],
|
||||||
|
[
|
||||||
|
['Processed', $stats['processed']],
|
||||||
|
['Inserted', $stats['inserted']],
|
||||||
|
['Duplicates (already exist)', $stats['duplicates']],
|
||||||
|
['Self-follows skipped', $stats['self_follows']],
|
||||||
|
['Users stub-imported from legacy', $stats['users_imported']],
|
||||||
|
['Invalid (orphan) — total', $stats['invalid']],
|
||||||
|
[' ↳ zero/null user_id', $stats['invalid_zero_id']],
|
||||||
|
[' ↳ not in new DB (not imported)', $stats['invalid_not_in_new']],
|
||||||
|
[' ↳ not in legacy DB either', $stats['invalid_not_in_legacy']],
|
||||||
|
[' ↳ legacy import error', $stats['invalid_import_error']],
|
||||||
|
['Errors', $stats['errors']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$summary = "Processed={$stats['processed']} Inserted={$stats['inserted']} "
|
||||||
|
. "Duplicates={$stats['duplicates']} SelfFollows={$stats['self_follows']} "
|
||||||
|
. "UsersImported={$stats['users_imported']} Invalid={$stats['invalid']} "
|
||||||
|
. "(ZeroId={$stats['invalid_zero_id']} NotInNew={$stats['invalid_not_in_new']} "
|
||||||
|
. "NotInLegacy={$stats['invalid_not_in_legacy']} ImportError={$stats['invalid_import_error']}) "
|
||||||
|
. "Errors={$stats['errors']}";
|
||||||
|
|
||||||
|
$this->logLine($logFile, "=== DONE: {$summary} ===");
|
||||||
|
fclose($logFile);
|
||||||
|
|
||||||
|
$this->info("Log written to: {$logPath}");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a legacy user_id exists in the new `users` table.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* true – user is valid (was already there, or was just imported / dry-run pretend-imported)
|
||||||
|
* null – user not found in the legacy DB either → cannot be imported
|
||||||
|
* false – user found in legacy DB but the stub-import threw an exception
|
||||||
|
*
|
||||||
|
* Results are cached per command run to avoid redundant DB queries.
|
||||||
|
*/
|
||||||
|
private function ensureLegacyUser(int $legacyId, bool $isDryRun, $logFile): ?bool
|
||||||
|
{
|
||||||
|
if (array_key_exists($legacyId, $this->legacyUserCache)) {
|
||||||
|
return $this->legacyUserCache[$legacyId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DB::table('users')->where('id', $legacyId)->exists()) {
|
||||||
|
return $this->legacyUserCache[$legacyId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacyUser = DB::connection('legacy')
|
||||||
|
->table('users')
|
||||||
|
->where('user_id', $legacyId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $legacyUser) {
|
||||||
|
$this->logLine($logFile, "IMPORT FAIL: user_id={$legacyId} not found in legacy DB");
|
||||||
|
return $this->legacyUserCache[$legacyId] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isDryRun) {
|
||||||
|
$this->logLine($logFile, "DRY-RUN IMPORT: would create user_id={$legacyId} uname={$legacyUser->uname}");
|
||||||
|
return $this->legacyUserCache[$legacyId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->importLegacyUserStub($legacyUser);
|
||||||
|
$this->logLine($logFile, "IMPORTED user_id={$legacyId} uname={$legacyUser->uname}");
|
||||||
|
return $this->legacyUserCache[$legacyId] = true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logLine($logFile, "IMPORT ERROR user_id={$legacyId}: " . $e->getMessage());
|
||||||
|
return $this->legacyUserCache[$legacyId] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importLegacyUserStub(object $row): void
|
||||||
|
{
|
||||||
|
$legacyId = (int) $row->user_id;
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
$username = UsernamePolicy::sanitizeLegacy((string) ($row->uname ?: ('user' . $legacyId)));
|
||||||
|
if (! $username) {
|
||||||
|
$username = 'user' . $legacyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DB::table('users')->whereRaw('LOWER(username) = ?', [strtolower($username)])->exists()) {
|
||||||
|
$username = $username . $legacyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = ($row->email ? strtolower(trim($row->email)) : null)
|
||||||
|
?: ('user' . $legacyId . '@users.skinbase.org');
|
||||||
|
|
||||||
|
DB::transaction(function () use ($legacyId, $username, $email, $row, $now) {
|
||||||
|
DB::table('users')->insertOrIgnore([
|
||||||
|
'id' => $legacyId,
|
||||||
|
'username' => $username,
|
||||||
|
'name' => $row->real_name ?: $username,
|
||||||
|
'email' => $email,
|
||||||
|
'password' => Hash::make(Str::random(32)),
|
||||||
|
'is_active' => (int) ($row->active ?? 1) === 1,
|
||||||
|
'needs_password_reset' => true,
|
||||||
|
'role' => 'user',
|
||||||
|
'created_at' => $row->joinDate ?? $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('user_profiles')->updateOrInsert(
|
||||||
|
['user_id' => $legacyId],
|
||||||
|
[
|
||||||
|
'country' => $row->country ?? null,
|
||||||
|
'country_code' => $row->country_code ? substr((string) $row->country_code, 0, 2) : null,
|
||||||
|
'website' => $row->web ?? null,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
DB::table('user_statistics')->updateOrInsert(
|
||||||
|
['user_id' => $legacyId],
|
||||||
|
['updated_at' => $now, 'created_at' => $now]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private function backfillCounters(): void
|
||||||
|
{
|
||||||
|
DB::statement('
|
||||||
|
UPDATE user_statistics us
|
||||||
|
JOIN (
|
||||||
|
SELECT user_id, COUNT(*) AS cnt
|
||||||
|
FROM user_followers
|
||||||
|
GROUP BY user_id
|
||||||
|
) AS f ON f.user_id = us.user_id
|
||||||
|
SET us.followers_count = f.cnt, us.updated_at = NOW()
|
||||||
|
');
|
||||||
|
|
||||||
|
DB::statement('
|
||||||
|
UPDATE user_statistics us
|
||||||
|
JOIN (
|
||||||
|
SELECT follower_id, COUNT(*) AS cnt
|
||||||
|
FROM user_followers
|
||||||
|
GROUP BY follower_id
|
||||||
|
) AS f ON f.follower_id = us.user_id
|
||||||
|
SET us.following_count = f.cnt, us.updated_at = NOW()
|
||||||
|
');
|
||||||
|
|
||||||
|
$this->info('Counters backfilled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logLine($handle, string $message): void
|
||||||
|
{
|
||||||
|
if (is_resource($handle)) {
|
||||||
|
fwrite($handle, '[' . now()->toISOString() . '] ' . $message . PHP_EOL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
246
app/Console/Commands/MigrateMessagesCommand.php
Normal file
246
app/Console/Commands/MigrateMessagesCommand.php
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Conversation;
|
||||||
|
use App\Models\ConversationParticipant;
|
||||||
|
use App\Models\Message;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates legacy `chat` / `messages` tables into the modern conversation-based system.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* 1. Load all legacy rows from the `chat` table via the 'legacy' DB connection.
|
||||||
|
* 2. Group by (sender_user_id, receiver_user_id) pair (canonical: min first).
|
||||||
|
* 3. For each pair, find or create a `direct` conversation.
|
||||||
|
* 4. Insert each message in chronological order.
|
||||||
|
* 5. Set last_read_at based on the legacy read_date column (if present).
|
||||||
|
* 6. Skip deleted / inactive rows.
|
||||||
|
* 7. Convert smileys to emoji placeholders.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan skinbase:migrate-messages
|
||||||
|
* php artisan skinbase:migrate-messages --dry-run
|
||||||
|
* php artisan skinbase:migrate-messages --chunk=1000
|
||||||
|
*/
|
||||||
|
class MigrateMessagesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:migrate-messages
|
||||||
|
{--dry-run : Preview only — no writes to DB}
|
||||||
|
{--chunk=500 : Rows to process per batch}';
|
||||||
|
|
||||||
|
protected $description = 'Migrate legacy chat/messages into the modern conversation system';
|
||||||
|
|
||||||
|
/** Columns we attempt to read; gracefully degrade if missing. */
|
||||||
|
private array $skipped = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('[DRY-RUN] No data will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check legacy connection ───────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
DB::connection('legacy')->getPdo();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$legacySchema = DB::connection('legacy')->getSchemaBuilder();
|
||||||
|
|
||||||
|
if (! $legacySchema->hasTable('chat')) {
|
||||||
|
$this->error('Legacy table `chat` not found on the legacy connection.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = $legacySchema->getColumnListing('chat');
|
||||||
|
$this->info('Legacy chat columns: ' . implode(', ', $columns));
|
||||||
|
|
||||||
|
// Map expected legacy columns (adapt if your legacy schema differs)
|
||||||
|
$hasReadDate = in_array('read_date', $columns, true);
|
||||||
|
$hasSoftDelete = in_array('deleted', $columns, true);
|
||||||
|
|
||||||
|
// ── Count total rows ──────────────────────────────────────────────────
|
||||||
|
$query = DB::connection('legacy')->table('chat');
|
||||||
|
|
||||||
|
if ($hasSoftDelete) {
|
||||||
|
$query->where('deleted', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = $query->count();
|
||||||
|
$this->info("Total legacy rows to process: {$total}");
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
$this->info('Nothing to migrate.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$inserted = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$offset = 0;
|
||||||
|
|
||||||
|
// ── Chunk processing ──────────────────────────────────────────────────
|
||||||
|
while (true) {
|
||||||
|
$rows = DB::connection('legacy')
|
||||||
|
->table('chat')
|
||||||
|
->when($hasSoftDelete, fn ($q) => $q->where('deleted', 0))
|
||||||
|
->orderBy('id')
|
||||||
|
->offset($offset)
|
||||||
|
->limit($chunk)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$senderId = (int) ($row->sender_user_id ?? $row->from_user_id ?? $row->user_id ?? 0);
|
||||||
|
$receiverId = (int) ($row->receiver_user_id ?? $row->to_user_id ?? $row->recipient_id ?? 0);
|
||||||
|
$body = trim((string) ($row->message ?? $row->body ?? $row->content ?? ''));
|
||||||
|
$createdAt = $row->created_at ?? $row->date ?? $row->timestamp ?? now();
|
||||||
|
$readDate = $hasReadDate ? $row->read_date : null;
|
||||||
|
|
||||||
|
if ($senderId === 0 || $receiverId === 0 || $body === '') {
|
||||||
|
$skipped++;
|
||||||
|
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'missing sender/receiver/body'];
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip self-messages
|
||||||
|
if ($senderId === $receiverId) {
|
||||||
|
$skipped++;
|
||||||
|
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => 'self-message'];
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize: strip HTML, convert smileys to emoji
|
||||||
|
$body = $this->sanitize($body);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$inserted++;
|
||||||
|
$bar->advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($senderId, $receiverId, $body, $createdAt, $readDate, &$inserted) {
|
||||||
|
// Find or create direct conversation
|
||||||
|
$conv = Conversation::findDirect($senderId, $receiverId);
|
||||||
|
|
||||||
|
if (! $conv) {
|
||||||
|
$conv = Conversation::create([
|
||||||
|
'type' => 'direct',
|
||||||
|
'created_by' => $senderId,
|
||||||
|
'last_message_at' => $createdAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ConversationParticipant::insert([
|
||||||
|
[
|
||||||
|
'conversation_id' => $conv->id,
|
||||||
|
'user_id' => $senderId,
|
||||||
|
'role' => 'admin',
|
||||||
|
'joined_at' => $createdAt,
|
||||||
|
'last_read_at' => $readDate,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'conversation_id' => $conv->id,
|
||||||
|
'user_id' => $receiverId,
|
||||||
|
'role' => 'member',
|
||||||
|
'joined_at' => $createdAt,
|
||||||
|
'last_read_at' => $readDate,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Update last_read_at on existing participants when available
|
||||||
|
if ($readDate) {
|
||||||
|
ConversationParticipant::where('conversation_id', $conv->id)
|
||||||
|
->where('user_id', $receiverId)
|
||||||
|
->whereNull('last_read_at')
|
||||||
|
->update(['last_read_at' => $readDate]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Message::create([
|
||||||
|
'conversation_id' => $conv->id,
|
||||||
|
'sender_id' => $senderId,
|
||||||
|
'body' => $body,
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
'updated_at' => $createdAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Keep last_message_at up to date
|
||||||
|
if ($conv->last_message_at < $createdAt) {
|
||||||
|
$conv->update(['last_message_at' => $createdAt]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$inserted++;
|
||||||
|
});
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$skipped++;
|
||||||
|
$this->skipped[] = ['id' => $row->id ?? '?', 'reason' => $e->getMessage()];
|
||||||
|
Log::warning('MigrateMessages: skipped row', [
|
||||||
|
'id' => $row->id ?? '?',
|
||||||
|
'reason' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset += $chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->info("Done. Inserted: {$inserted} | Skipped: {$skipped}");
|
||||||
|
|
||||||
|
if ($skipped > 0 && $this->option('verbose')) {
|
||||||
|
$this->table(['ID', 'Reason'], $this->skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip HTML tags and convert common legacy smileys to emoji.
|
||||||
|
*/
|
||||||
|
private function sanitize(string $body): string
|
||||||
|
{
|
||||||
|
// Strip raw HTML
|
||||||
|
$body = strip_tags($body);
|
||||||
|
|
||||||
|
// Decode HTML entities
|
||||||
|
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
// Common smiley → emoji mapping
|
||||||
|
$smileys = [
|
||||||
|
':)' => '🙂', ':-)' => '🙂',
|
||||||
|
':(' => '🙁', ':-(' => '🙁',
|
||||||
|
':D' => '😀', ':-D' => '😀',
|
||||||
|
':P' => '😛', ':-P' => '😛',
|
||||||
|
';)' => '😉', ';-)' => '😉',
|
||||||
|
':o' => '😮', ':O' => '😮',
|
||||||
|
':|' => '😐', ':-|' => '😐',
|
||||||
|
':/' => '😕', ':-/' => '😕',
|
||||||
|
'<3' => '❤️',
|
||||||
|
'xD' => '😂', 'XD' => '😂',
|
||||||
|
];
|
||||||
|
|
||||||
|
return str_replace(array_keys($smileys), array_values($smileys), $body);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/Console/Commands/MigrateSmileys.php
Normal file
143
app/Console/Commands/MigrateSmileys.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\LegacySmileyMapper;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* php artisan skinbase:migrate-smileys
|
||||||
|
*
|
||||||
|
* Scans artworks.description, artwork_comments.content, and forum_posts.content,
|
||||||
|
* replaces legacy smiley codes (:beer, :lol, etc.) with Unicode emoji.
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --dry-run Show what would change without writing to DB
|
||||||
|
* --chunk=200 Rows processed per batch (default 200)
|
||||||
|
* --table=artworks Limit scan to one table
|
||||||
|
*/
|
||||||
|
class MigrateSmileys extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:migrate-smileys
|
||||||
|
{--dry-run : Preview changes without writing to the database}
|
||||||
|
{--chunk=200 : Number of rows to process per batch}
|
||||||
|
{--table= : Limit scan to a single table (artworks|artwork_comments|forum_posts)}';
|
||||||
|
|
||||||
|
protected $description = 'Convert legacy :smiley: codes to Unicode emoji in content fields.';
|
||||||
|
|
||||||
|
/** Tables and their content columns to scan. */
|
||||||
|
private const TARGETS = [
|
||||||
|
'artworks' => 'description',
|
||||||
|
'artwork_comments' => 'content',
|
||||||
|
'forum_posts' => 'content',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$tableOpt = $this->option('table');
|
||||||
|
|
||||||
|
$targets = self::TARGETS;
|
||||||
|
if ($tableOpt) {
|
||||||
|
if (! isset($targets[$tableOpt])) {
|
||||||
|
$this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets)));
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
$targets = [$tableOpt => $targets[$tableOpt]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY-RUN mode — no changes will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalChanged = 0;
|
||||||
|
$totalRows = 0;
|
||||||
|
|
||||||
|
foreach ($targets as $table => $column) {
|
||||||
|
$this->line("Scanning <info>{$table}.{$column}</info>…");
|
||||||
|
|
||||||
|
[$changed, $rows] = $this->processTable($table, $column, $chunk, $dryRun);
|
||||||
|
|
||||||
|
$totalChanged += $changed;
|
||||||
|
$totalRows += $rows;
|
||||||
|
|
||||||
|
$this->line(" → {$rows} rows scanned, {$changed} updated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Summary: {$totalRows} rows scanned, {$totalChanged} rows " . ($dryRun ? 'would be ' : '') . 'updated.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processTable(
|
||||||
|
string $table,
|
||||||
|
string $column,
|
||||||
|
int $chunk,
|
||||||
|
bool $dryRun
|
||||||
|
): array {
|
||||||
|
$totalChanged = 0;
|
||||||
|
$totalRows = 0;
|
||||||
|
|
||||||
|
DB::table($table)
|
||||||
|
->whereNotNull($column)
|
||||||
|
->orderBy('id')
|
||||||
|
->chunk($chunk, function ($rows) use ($table, $column, $dryRun, &$totalChanged, &$totalRows) {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$original = $row->$column ?? '';
|
||||||
|
$converted = LegacySmileyMapper::convert($original);
|
||||||
|
|
||||||
|
// Collapse emoji flood runs BEFORE size/DB checks so that
|
||||||
|
// rows like ":beer :beer :beer …" (×500) don't exceed MEDIUMTEXT.
|
||||||
|
$collapsed = LegacySmileyMapper::collapseFlood($converted);
|
||||||
|
if ($collapsed !== $converted) {
|
||||||
|
$beforeBytes = mb_strlen($converted, '8bit');
|
||||||
|
$afterBytes = mb_strlen($collapsed, '8bit');
|
||||||
|
$floodMsg = "[{$table}#{$row->id}] Emoji flood collapsed "
|
||||||
|
. "({$beforeBytes} bytes \u{2192} {$afterBytes} bytes).";
|
||||||
|
$this->warn(" {$floodMsg}");
|
||||||
|
Log::warning($floodMsg);
|
||||||
|
$converted = $collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalRows++;
|
||||||
|
|
||||||
|
if ($converted === $original) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalChanged++;
|
||||||
|
|
||||||
|
$codes = LegacySmileyMapper::detect($original);
|
||||||
|
$msg = "[{$table}#{$row->id}] Converting: " . implode(', ', $codes);
|
||||||
|
$this->line(" {$msg}");
|
||||||
|
Log::info($msg);
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
// Guard: MEDIUMTEXT max is 16,777,215 bytes.
|
||||||
|
if (mb_strlen($converted, '8bit') > 16_777_215) {
|
||||||
|
$warn = "[{$table}#{$row->id}] SKIP — converted content exceeds MEDIUMTEXT limit (" . mb_strlen($converted, '8bit') . " bytes). Row left unchanged.";
|
||||||
|
$this->warn(" {$warn}");
|
||||||
|
Log::warning($warn);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::table($table)
|
||||||
|
->where('id', $row->id)
|
||||||
|
->update([$column => $converted]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$err = "[{$table}#{$row->id}] DB error: {$e->getMessage()}";
|
||||||
|
$this->warn(" {$err}");
|
||||||
|
Log::error($err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [$totalChanged, $totalRows];
|
||||||
|
}
|
||||||
|
}
|
||||||
197
app/Console/Commands/MigrateStoriesCommand.php
Normal file
197
app/Console/Commands/MigrateStoriesCommand.php
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Story;
|
||||||
|
use App\Models\StoryAuthor;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate legacy interview records into the new Stories system.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan stories:migrate-legacy
|
||||||
|
* php artisan stories:migrate-legacy --dry-run
|
||||||
|
* php artisan stories:migrate-legacy --legacy-connection=legacy --chunk=100
|
||||||
|
*
|
||||||
|
* Idempotent: running multiple times will not duplicate records.
|
||||||
|
* Legacy records are identified via `legacy_interview_id` column on stories table.
|
||||||
|
*/
|
||||||
|
final class MigrateStoriesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'stories:migrate-legacy
|
||||||
|
{--chunk=50 : number of records to process per batch}
|
||||||
|
{--dry-run : preview migration without persisting changes}
|
||||||
|
{--legacy-connection= : DB connection name for legacy database (default: uses default connection)}
|
||||||
|
{--legacy-table=interviews : legacy interviews table name}
|
||||||
|
';
|
||||||
|
|
||||||
|
protected $description = 'Migrate legacy interview records into the new nova Stories system (idempotent)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$legacyConn = $this->option('legacy-connection') ?: null;
|
||||||
|
$table = (string) $this->option('legacy-table');
|
||||||
|
|
||||||
|
$this->info('Nova Stories — legacy interview migration');
|
||||||
|
$this->info("Table: {$table} | Chunk: {$chunk} | Dry-run: " . ($dryRun ? 'YES' : 'NO'));
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = $legacyConn ? DB::connection($legacyConn) : DB::connection();
|
||||||
|
// Quick existence check
|
||||||
|
$db->table($table)->limit(1)->get();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error("Cannot access table `{$table}`: " . $e->getMessage());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inserted = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
$db->table($table)->orderBy('id')->chunkById($chunk, function ($rows) use (
|
||||||
|
$dryRun, &$inserted, &$skipped, &$failed
|
||||||
|
) {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$legacyId = (int) ($row->id ?? 0);
|
||||||
|
|
||||||
|
if (! $legacyId) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: skip if already migrated
|
||||||
|
if (Story::where('legacy_interview_id', $legacyId)->exists()) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Resolve / create author ──────────────────────────────
|
||||||
|
$authorName = $this->coerceString($row->username ?? $row->author ?? $row->uname ?? '');
|
||||||
|
$authorAvatar = $this->coerceString($row->icon ?? $row->avatar ?? '');
|
||||||
|
|
||||||
|
$author = null;
|
||||||
|
if ($authorName) {
|
||||||
|
$author = StoryAuthor::firstOrCreate(
|
||||||
|
['name' => $authorName],
|
||||||
|
['avatar' => $authorAvatar ?: null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build slug ───────────────────────────────────────────
|
||||||
|
$rawTitle = $this->coerceString(
|
||||||
|
$row->headline ?? $row->title ?? $row->subject ?? ''
|
||||||
|
) ?: 'interview-' . $legacyId;
|
||||||
|
|
||||||
|
$slugBase = Str::slug(Str::limit($rawTitle, 180));
|
||||||
|
$slug = $slugBase ?: 'interview-' . $legacyId;
|
||||||
|
|
||||||
|
// Ensure uniqueness
|
||||||
|
$slug = $this->uniqueSlug($slug);
|
||||||
|
|
||||||
|
// ── Excerpt ──────────────────────────────────────────────
|
||||||
|
$fullContent = $this->coerceString(
|
||||||
|
$row->content ?? $row->tekst ?? $row->body ?? $row->text ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$excerpt = $this->coerceString($row->excerpt ?? $row->intro ?? $row->lead ?? '');
|
||||||
|
if (! $excerpt && $fullContent) {
|
||||||
|
$excerpt = Str::limit(strip_tags($fullContent), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cover image ──────────────────────────────────────────
|
||||||
|
$coverRaw = $this->coerceString($row->pic ?? $row->image ?? $row->cover ?? $row->photo ?? '');
|
||||||
|
$coverImage = $coverRaw ? 'legacy/interviews/' . ltrim($coverRaw, '/') : null;
|
||||||
|
|
||||||
|
// ── Published date ───────────────────────────────────────
|
||||||
|
$publishedAt = null;
|
||||||
|
foreach (['datum', 'published_at', 'date', 'created_at'] as $field) {
|
||||||
|
$val = $row->{$field} ?? null;
|
||||||
|
if ($val) {
|
||||||
|
$ts = strtotime((string) $val);
|
||||||
|
if ($ts) {
|
||||||
|
$publishedAt = date('Y-m-d H:i:s', $ts);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(" [DRY-RUN] Would import: #{$legacyId} → {$slug}");
|
||||||
|
$inserted++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Story::create([
|
||||||
|
'slug' => $slug,
|
||||||
|
'title' => Str::limit($rawTitle, 255),
|
||||||
|
'excerpt' => $excerpt ?: null,
|
||||||
|
'content' => $fullContent ?: null,
|
||||||
|
'cover_image' => $coverImage,
|
||||||
|
'author_id' => $author?->id,
|
||||||
|
'views' => max(0, (int) ($row->views ?? $row->hits ?? 0)),
|
||||||
|
'featured' => false,
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => $publishedAt,
|
||||||
|
'legacy_interview_id' => $legacyId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->line(" Imported: #{$legacyId} → {$slug}");
|
||||||
|
$inserted++;
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$this->warn(" FAILED #{$legacyId}: " . $e->getMessage());
|
||||||
|
Log::warning("stories:migrate-legacy failed for id={$legacyId}", ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Migration complete.");
|
||||||
|
$this->table(
|
||||||
|
['Inserted', 'Skipped (existing)', 'Failed'],
|
||||||
|
[[$inserted, $skipped, $failed]]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function coerceString(mixed $value, string $default = ''): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
$str = trim((string) $value);
|
||||||
|
return $str !== '' ? $str : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the slug is unique, appending a numeric suffix if needed.
|
||||||
|
*/
|
||||||
|
private function uniqueSlug(string $slug): string
|
||||||
|
{
|
||||||
|
if (! Story::where('slug', $slug)->exists()) {
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
$i = 2;
|
||||||
|
do {
|
||||||
|
$candidate = $slug . '-' . $i++;
|
||||||
|
} while (Story::where('slug', $candidate)->exists());
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/Console/Commands/MigrateWallzStatsCommand.php
Normal file
123
app/Console/Commands/MigrateWallzStatsCommand.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy views and downloads from the legacy `wallz` table into `artwork_stats`.
|
||||||
|
*
|
||||||
|
* Uses wallz.id as artwork_id.
|
||||||
|
* Rows that already exist are updated; missing rows are inserted with zeros
|
||||||
|
* for all other counters.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan skinbase:migrate-wallz-stats
|
||||||
|
* php artisan skinbase:migrate-wallz-stats --chunk=500 --dry-run
|
||||||
|
*/
|
||||||
|
class MigrateWallzStatsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:migrate-wallz-stats
|
||||||
|
{--chunk=1000 : Number of wallz rows to process per batch}
|
||||||
|
{--dry-run : Preview counts without writing to the database}';
|
||||||
|
|
||||||
|
protected $description = 'Import views and downloads from legacy wallz table into artwork_stats';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$chunkSize = (int) $this->option('chunk');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('[DRY RUN] No data will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (int) DB::connection('legacy')->table('wallz')->count();
|
||||||
|
$processed = 0;
|
||||||
|
$inserted = 0;
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
$this->info("Found {$total} rows in legacy wallz table. Chunk size: {$chunkSize}.");
|
||||||
|
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% — ins: %message%');
|
||||||
|
$bar->setMessage('0 ins / 0 upd');
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
DB::connection('legacy')
|
||||||
|
->table('wallz')
|
||||||
|
->select('id', 'views', 'dls', 'rating', 'rating_num')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunk($chunkSize, function ($rows) use ($dryRun, &$processed, &$inserted, &$updated, $bar) {
|
||||||
|
$artworkIds = $rows->pluck('id')->all();
|
||||||
|
|
||||||
|
// Find which artwork_ids already have a stats row.
|
||||||
|
$existing = DB::table('artwork_stats')
|
||||||
|
->whereIn('artwork_id', $artworkIds)
|
||||||
|
->pluck('artwork_id')
|
||||||
|
->flip(); // flip → [artwork_id => index] for O(1) lookup
|
||||||
|
|
||||||
|
$toInsert = [];
|
||||||
|
$now = now()->toDateTimeString();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$views = max(0, (int) $row->views);
|
||||||
|
$dls = max(0, (int) $row->dls);
|
||||||
|
$ratingAvg = max(0, (float) $row->rating);
|
||||||
|
$ratingCount = max(0, (int) $row->rating_num);
|
||||||
|
|
||||||
|
if ($existing->has($row->id)) {
|
||||||
|
// Update existing row.
|
||||||
|
if (! $dryRun) {
|
||||||
|
DB::table('artwork_stats')
|
||||||
|
->where('artwork_id', $row->id)
|
||||||
|
->update([
|
||||||
|
'views' => $views,
|
||||||
|
'downloads' => $dls,
|
||||||
|
'rating_avg' => $ratingAvg,
|
||||||
|
'rating_count' => $ratingCount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$updated++;
|
||||||
|
} else {
|
||||||
|
// Batch-collect for insert.
|
||||||
|
$toInsert[] = [
|
||||||
|
'artwork_id' => $row->id,
|
||||||
|
'views' => $views,
|
||||||
|
'views_24h' => 0,
|
||||||
|
'views_7d' => 0,
|
||||||
|
'downloads' => $dls,
|
||||||
|
'downloads_24h' => 0,
|
||||||
|
'downloads_7d' => 0,
|
||||||
|
'favorites' => 0,
|
||||||
|
'rating_avg' => $ratingAvg,
|
||||||
|
'rating_count' => $ratingCount,
|
||||||
|
];
|
||||||
|
$inserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun && ! empty($toInsert)) {
|
||||||
|
DB::table('artwork_stats')->insertOrIgnore($toInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed += count($rows);
|
||||||
|
$bar->setMessage("{$inserted} ins / {$updated} upd");
|
||||||
|
$bar->advance(count($rows));
|
||||||
|
});
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn("DRY RUN complete — would insert {$inserted}, update {$updated} ({$processed} rows scanned).");
|
||||||
|
} else {
|
||||||
|
$this->info("Done — inserted {$inserted}, updated {$updated} ({$processed} rows processed).");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune old hourly metric snapshots to prevent unbounded table growth.
|
||||||
|
*
|
||||||
|
* Usage: php artisan nova:prune-metric-snapshots
|
||||||
|
* php artisan nova:prune-metric-snapshots --keep-days=7
|
||||||
|
*/
|
||||||
|
class PruneMetricSnapshotsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'nova:prune-metric-snapshots
|
||||||
|
{--keep-days=7 : Keep snapshots for this many days}';
|
||||||
|
|
||||||
|
protected $description = 'Delete old hourly metric snapshots beyond the retention window';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$keepDays = (int) $this->option('keep-days');
|
||||||
|
$cutoff = now()->subDays($keepDays);
|
||||||
|
|
||||||
|
$deleted = DB::table('artwork_metric_snapshots_hourly')
|
||||||
|
->where('bucket_hour', '<', $cutoff)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days.");
|
||||||
|
|
||||||
|
Log::info('[nova:prune-metric-snapshots] completed', [
|
||||||
|
'deleted' => $deleted,
|
||||||
|
'keep_days' => $keepDays,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Console/Commands/PruneViewEventsCommand.php
Normal file
42
app/Console/Commands/PruneViewEventsCommand.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete artwork_view_events rows older than N days.
|
||||||
|
*
|
||||||
|
* The view event log grows ~proportionally to site traffic. Rows beyond the
|
||||||
|
* retention window are no longer useful for trending (which looks back ≤7
|
||||||
|
* days) or for computing "recently viewed" lists in the UI.
|
||||||
|
*
|
||||||
|
* Default retention is 90 days — long enough for analytics queries and user
|
||||||
|
* history pages, short enough to keep the table from growing unbounded.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan skinbase:prune-view-events
|
||||||
|
* php artisan skinbase:prune-view-events --days=30
|
||||||
|
*/
|
||||||
|
class PruneViewEventsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:prune-view-events {--days=90 : Delete events older than this many days}';
|
||||||
|
protected $description = 'Delete artwork_view_events rows older than N days';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$cutoff = now()->subDays($days);
|
||||||
|
|
||||||
|
$deleted = DB::table('artwork_view_events')
|
||||||
|
->where('viewed_at', '<', $cutoff)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info("Pruned {$deleted} view event(s) older than {$days} days (cutoff: {$cutoff}).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/Console/Commands/PublishScheduledArtworksCommand.php
Normal file
122
app/Console/Commands/PublishScheduledArtworksCommand.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ActivityEvent;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PublishScheduledArtworksCommand
|
||||||
|
*
|
||||||
|
* Runs every minute (via Kernel schedule).
|
||||||
|
* Finds artworks with:
|
||||||
|
* - artwork_status = 'scheduled'
|
||||||
|
* - publish_at <= now() (UTC)
|
||||||
|
* - is_approved = true (respect moderation gate)
|
||||||
|
*
|
||||||
|
* Publishes each one:
|
||||||
|
* - sets is_public = true
|
||||||
|
* - sets published_at = now()
|
||||||
|
* - sets artwork_status = 'published'
|
||||||
|
* - dispatches Meilisearch reindex (via Scout)
|
||||||
|
* - records activity event
|
||||||
|
*
|
||||||
|
* Safe to run concurrently (DB row lock prevents double-publish).
|
||||||
|
*/
|
||||||
|
class PublishScheduledArtworksCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:publish-scheduled
|
||||||
|
{--dry-run : List candidate artworks without publishing}
|
||||||
|
{--limit=100 : Max artworks to process per run}';
|
||||||
|
|
||||||
|
protected $description = 'Publish scheduled artworks whose publish_at datetime has passed.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$limit = (int) $this->option('limit');
|
||||||
|
|
||||||
|
$now = now()->utc();
|
||||||
|
|
||||||
|
$candidates = Artwork::query()
|
||||||
|
->where('artwork_status', 'scheduled')
|
||||||
|
->where('publish_at', '<=', $now)
|
||||||
|
->where('is_approved', true)
|
||||||
|
->orderBy('publish_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get(['id', 'user_id', 'title', 'publish_at', 'artwork_status']);
|
||||||
|
|
||||||
|
if ($candidates->isEmpty()) {
|
||||||
|
$this->line('No scheduled artworks due for publishing.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$candidates->count()} artwork(s) to publish." . ($dryRun ? ' [DRY RUN]' : ''));
|
||||||
|
|
||||||
|
$published = 0;
|
||||||
|
$errors = 0;
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(" [dry-run] Would publish artwork #{$candidate->id}: \"{$candidate->title}\"");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($candidate, $now, &$published) {
|
||||||
|
// Re-fetch with lock to avoid double-publish in concurrent runs
|
||||||
|
$artwork = Artwork::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->where('id', $candidate->id)
|
||||||
|
->where('artwork_status', 'scheduled')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $artwork) {
|
||||||
|
// Already published or status changed – skip
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork->is_public = true;
|
||||||
|
$artwork->published_at = $now;
|
||||||
|
$artwork->artwork_status = 'published';
|
||||||
|
$artwork->save();
|
||||||
|
|
||||||
|
// Trigger Meilisearch reindex via Scout (if searchable trait present)
|
||||||
|
if (method_exists($artwork, 'searchable')) {
|
||||||
|
try {
|
||||||
|
$artwork->searchable();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record activity event
|
||||||
|
try {
|
||||||
|
ActivityEvent::record(
|
||||||
|
actorId: (int) $artwork->user_id,
|
||||||
|
type: ActivityEvent::TYPE_UPLOAD,
|
||||||
|
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||||
|
targetId: (int) $artwork->id,
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
$published++;
|
||||||
|
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors++;
|
||||||
|
Log::error("PublishScheduledArtworksCommand: failed to publish artwork #{$candidate->id}: {$e->getMessage()}");
|
||||||
|
$this->error(" Failed to publish #{$candidate->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->info("Done. Published: {$published}, Errors: {$errors}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Console/Commands/PublishScheduledPostsCommand.php
Normal file
46
app/Console/Commands/PublishScheduledPostsCommand.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes posts whose publish_at timestamp has passed.
|
||||||
|
* Scheduled every minute via console/kernel.
|
||||||
|
*/
|
||||||
|
class PublishScheduledPostsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'posts:publish-scheduled';
|
||||||
|
protected $description = 'Publish all scheduled posts whose publish_at time has been reached.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$count = Post::where('status', Post::STATUS_SCHEDULED)
|
||||||
|
->where('publish_at', '<=', now())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
$this->line('No scheduled posts to publish.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$published = 0;
|
||||||
|
|
||||||
|
Post::where('status', Post::STATUS_SCHEDULED)
|
||||||
|
->where('publish_at', '<=', now())
|
||||||
|
->chunkById(100, function ($posts) use (&$published) {
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
DB::transaction(function () use ($post) {
|
||||||
|
$post->update(['status' => Post::STATUS_PUBLISHED]);
|
||||||
|
});
|
||||||
|
$published++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("Published {$published} scheduled post(s).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Console/Commands/RebuildArtworkSearchIndex.php
Normal file
30
app/Console/Commands/RebuildArtworkSearchIndex.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\ArtworkSearchIndexer;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RebuildArtworkSearchIndex extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:search-rebuild {--chunk=500 : Number of artworks per chunk}';
|
||||||
|
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based).';
|
||||||
|
|
||||||
|
public function __construct(private readonly ArtworkSearchIndexer $indexer)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$chunk = (int) $this->option('chunk');
|
||||||
|
|
||||||
|
$this->info("Dispatching index jobs in chunks of {$chunk}…");
|
||||||
|
$this->indexer->rebuildAll($chunk);
|
||||||
|
$this->info('All jobs dispatched. Workers will process them asynchronously.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate heat_score for artworks based on hourly metric snapshots.
|
||||||
|
*
|
||||||
|
* Runs every 10–15 minutes via scheduler.
|
||||||
|
*
|
||||||
|
* Formula:
|
||||||
|
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||||||
|
* + comments_delta*8 + shares_delta*12
|
||||||
|
*
|
||||||
|
* age_factor = 1 / (1 + hours_since_upload / 24)
|
||||||
|
*
|
||||||
|
* heat_score = raw_heat * age_factor
|
||||||
|
*
|
||||||
|
* Usage: php artisan nova:recalculate-heat
|
||||||
|
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
|
||||||
|
*/
|
||||||
|
class RecalculateHeatCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'nova:recalculate-heat
|
||||||
|
{--days=60 : Only process artworks created within this many days}
|
||||||
|
{--chunk=1000 : Chunk size for DB queries}
|
||||||
|
{--dry-run : Compute scores without writing to DB}';
|
||||||
|
|
||||||
|
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
|
||||||
|
|
||||||
|
/** Delta weights per the spec */
|
||||||
|
private const WEIGHTS = [
|
||||||
|
'views' => 1,
|
||||||
|
'downloads' => 3,
|
||||||
|
'favourites' => 6,
|
||||||
|
'comments' => 8,
|
||||||
|
'shares' => 12,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$chunk = (int) $this->option('chunk');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$now = now();
|
||||||
|
$currentHour = $now->copy()->startOfHour();
|
||||||
|
$prevHour = $currentHour->copy()->subHour();
|
||||||
|
|
||||||
|
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
|
||||||
|
|
||||||
|
$updatedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
|
||||||
|
// Process in chunks using artwork IDs that have at least one snapshot in the two hours
|
||||||
|
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
|
||||||
|
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||||
|
->distinct()
|
||||||
|
->pluck('artwork_id');
|
||||||
|
|
||||||
|
if ($artworkIds->isEmpty()) {
|
||||||
|
$this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all snapshots for the two hours in bulk
|
||||||
|
$snapshots = DB::table('artwork_metric_snapshots_hourly')
|
||||||
|
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||||
|
->whereIn('artwork_id', $artworkIds)
|
||||||
|
->get()
|
||||||
|
->groupBy('artwork_id');
|
||||||
|
|
||||||
|
// Load artwork published_at dates for age factor (use published_at, fall back to created_at)
|
||||||
|
$artworkDates = DB::table('artworks')
|
||||||
|
->whereIn('id', $artworkIds)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('is_approved', true)
|
||||||
|
->select('id', 'published_at', 'created_at')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(fn ($row) => [
|
||||||
|
$row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Process in chunks
|
||||||
|
foreach ($artworkIds->chunk($chunk) as $chunkIds) {
|
||||||
|
$upsertRows = [];
|
||||||
|
|
||||||
|
foreach ($chunkIds as $artworkId) {
|
||||||
|
$createdAt = $artworkDates->get($artworkId);
|
||||||
|
if (!$createdAt) {
|
||||||
|
$skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworkSnapshots = $snapshots->get($artworkId);
|
||||||
|
if (!$artworkSnapshots || $artworkSnapshots->isEmpty()) {
|
||||||
|
$skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
|
||||||
|
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||||||
|
|
||||||
|
// If we only have one snapshot, use it as current with zero deltas
|
||||||
|
if (!$currentSnapshot && !$prevSnapshot) {
|
||||||
|
$skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate deltas
|
||||||
|
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
||||||
|
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
|
||||||
|
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
|
||||||
|
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
|
||||||
|
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
|
||||||
|
|
||||||
|
// Raw heat
|
||||||
|
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
|
||||||
|
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||||||
|
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||||||
|
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||||||
|
+ ($sharesDelta * self::WEIGHTS['shares']);
|
||||||
|
|
||||||
|
// Age factor: favors newer works
|
||||||
|
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
|
||||||
|
$ageFactor = 1.0 / (1.0 + ($hoursSinceUpload / 24.0));
|
||||||
|
|
||||||
|
// Final heat score
|
||||||
|
$heatScore = max(0, $rawHeat * $ageFactor);
|
||||||
|
|
||||||
|
$upsertRows[] = [
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'heat_score' => round($heatScore, 4),
|
||||||
|
'heat_score_updated_at' => $now,
|
||||||
|
'views_1h' => $viewsDelta,
|
||||||
|
'downloads_1h' => $downloadsDelta,
|
||||||
|
'favourites_1h' => $favouritesDelta,
|
||||||
|
'comments_1h' => $commentsDelta,
|
||||||
|
'shares_1h' => $sharesDelta,
|
||||||
|
];
|
||||||
|
|
||||||
|
$updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dryRun && !empty($upsertRows)) {
|
||||||
|
DB::table('artwork_stats')->upsert(
|
||||||
|
$upsertRows,
|
||||||
|
['artwork_id'],
|
||||||
|
['heat_score', 'heat_score_updated_at', 'views_1h', 'downloads_1h', 'favourites_1h', 'comments_1h', 'shares_1h']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Heat scores updated: {$updatedCount} | Skipped: {$skippedCount}");
|
||||||
|
|
||||||
|
Log::info('[nova:recalculate-heat] completed', [
|
||||||
|
'updated' => $updatedCount,
|
||||||
|
'skipped' => $skippedCount,
|
||||||
|
'dry_run' => $dryRun,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Console/Commands/RecalculateRankingsCommand.php
Normal file
81
app/Console/Commands/RecalculateRankingsCommand.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Ranking\ArtworkRankingService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* php artisan nova:recalculate-rankings [--chunk=500] [--sync-rank-scores] [--skip-index]
|
||||||
|
*
|
||||||
|
* Ranking Engine V2 — recalculates ranking_score and engagement_velocity
|
||||||
|
* for all public, approved artworks. Designed to run every 30 minutes.
|
||||||
|
*/
|
||||||
|
class RecalculateRankingsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'nova:recalculate-rankings
|
||||||
|
{--chunk=500 : DB chunk size for batch processing}
|
||||||
|
{--sync-rank-scores : Also update rank_artwork_scores table with V2 formula}
|
||||||
|
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
|
||||||
|
|
||||||
|
protected $description = 'Recalculate V2 ranking scores (engagement + shares + decay + authority + velocity)';
|
||||||
|
|
||||||
|
public function __construct(private readonly ArtworkRankingService $ranking)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$chunkSize = (int) $this->option('chunk');
|
||||||
|
$syncRankScores = (bool) $this->option('sync-rank-scores');
|
||||||
|
$skipIndex = (bool) $this->option('skip-index');
|
||||||
|
|
||||||
|
// ── Step 1: Recalculate ranking_score + engagement_velocity ─────
|
||||||
|
$this->info('Ranking V2: recalculating scores …');
|
||||||
|
$start = microtime(true);
|
||||||
|
$updated = $this->ranking->recalculateAll($chunkSize);
|
||||||
|
$elapsed = round(microtime(true) - $start, 2);
|
||||||
|
$this->info(" ✓ {$updated} artworks scored in {$elapsed}s");
|
||||||
|
|
||||||
|
// ── Step 2 (optional): Sync to rank_artwork_scores ─────────────
|
||||||
|
if ($syncRankScores) {
|
||||||
|
$this->info('Syncing to rank_artwork_scores …');
|
||||||
|
$start2 = microtime(true);
|
||||||
|
$synced = $this->ranking->syncToRankScores($chunkSize);
|
||||||
|
$elapsed2 = round(microtime(true) - $start2, 2);
|
||||||
|
$this->info(" ✓ {$synced} rank scores synced in {$elapsed2}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3 (optional): Trigger Meilisearch re-index ────────────
|
||||||
|
if (! $skipIndex) {
|
||||||
|
$this->info('Dispatching Meilisearch index jobs …');
|
||||||
|
$this->dispatchIndexJobs();
|
||||||
|
$this->info(' ✓ Index jobs dispatched');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch IndexArtworkJob for artworks updated in the last 24 hours
|
||||||
|
* (or recently scored). Keeps the search index current.
|
||||||
|
*/
|
||||||
|
private function dispatchIndexJobs(): void
|
||||||
|
{
|
||||||
|
\App\Models\Artwork::query()
|
||||||
|
->select('id')
|
||||||
|
->where('is_public', true)
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereNotNull('published_at')
|
||||||
|
->where('published_at', '>=', now()->subDays(30)->toDateTimeString())
|
||||||
|
->chunkById(500, function ($artworks): void {
|
||||||
|
foreach ($artworks as $artwork) {
|
||||||
|
\App\Jobs\IndexArtworkJob::dispatch($artwork->id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Console/Commands/RecalculateTrendingCommand.php
Normal file
57
app/Console/Commands/RecalculateTrendingCommand.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\TrendingService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* php artisan skinbase:recalculate-trending [--period=24h|7d] [--chunk=1000] [--skip-index]
|
||||||
|
*/
|
||||||
|
class RecalculateTrendingCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:recalculate-trending
|
||||||
|
{--period=7d : Period to recalculate (24h or 7d). Use "all" to run both.}
|
||||||
|
{--chunk=1000 : DB chunk size}
|
||||||
|
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
|
||||||
|
|
||||||
|
protected $description = 'Recalculate trending scores for artworks and sync to Meilisearch';
|
||||||
|
|
||||||
|
public function __construct(private readonly TrendingService $trending)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$period = (string) $this->option('period');
|
||||||
|
$chunkSize = (int) $this->option('chunk');
|
||||||
|
$skipIndex = (bool) $this->option('skip-index');
|
||||||
|
|
||||||
|
$periods = $period === 'all' ? ['24h', '7d'] : [$period];
|
||||||
|
|
||||||
|
foreach ($periods as $p) {
|
||||||
|
if (! in_array($p, ['24h', '7d'], true)) {
|
||||||
|
$this->error("Invalid period '{$p}'. Use 24h, 7d, or all.");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Recalculating trending ({$p}) …");
|
||||||
|
$start = microtime(true);
|
||||||
|
$updated = $this->trending->recalculate($p, $chunkSize);
|
||||||
|
$elapsed = round(microtime(true) - $start, 2);
|
||||||
|
|
||||||
|
$this->info(" ✓ {$updated} artworks updated in {$elapsed}s");
|
||||||
|
|
||||||
|
if (! $skipIndex) {
|
||||||
|
$this->info(" Dispatching Meilisearch index jobs …");
|
||||||
|
$this->trending->syncToSearchIndex($p);
|
||||||
|
$this->info(" ✓ Index jobs dispatched");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
app/Console/Commands/RecalculateUserXpCommand.php
Normal file
163
app/Console/Commands/RecalculateUserXpCommand.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\AchievementService;
|
||||||
|
use App\Services\XPService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class RecalculateUserXpCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:recalculate-user-xp
|
||||||
|
{user_id? : The ID of a single user to recompute}
|
||||||
|
{--all : Recompute XP and level for all non-deleted users}
|
||||||
|
{--chunk=1000 : Chunk size when --all is used}
|
||||||
|
{--dry-run : Show computed values without writing}
|
||||||
|
{--sync-achievements : Re-run achievement checks after a live recalculation}';
|
||||||
|
|
||||||
|
protected $description = 'Rebuild stored user XP, level, and rank from user_xp_logs';
|
||||||
|
|
||||||
|
public function handle(XPService $xp, AchievementService $achievements): int
|
||||||
|
{
|
||||||
|
$userId = $this->argument('user_id');
|
||||||
|
$all = (bool) $this->option('all');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$syncAchievements = (bool) $this->option('sync-achievements');
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
|
||||||
|
if ($userId !== null && $all) {
|
||||||
|
$this->error('Provide either a user_id or --all, not both.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userId !== null) {
|
||||||
|
return $this->recalculateSingle((int) $userId, $xp, $achievements, $dryRun, $syncAchievements);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($all) {
|
||||||
|
return $this->recalculateAll($xp, $achievements, $chunk, $dryRun, $syncAchievements);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Provide a user_id or use --all.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recalculateSingle(
|
||||||
|
int $userId,
|
||||||
|
XPService $xp,
|
||||||
|
AchievementService $achievements,
|
||||||
|
bool $dryRun,
|
||||||
|
bool $syncAchievements,
|
||||||
|
): int {
|
||||||
|
$exists = DB::table('users')->where('id', $userId)->exists();
|
||||||
|
if (! $exists) {
|
||||||
|
$this->error("User {$userId} not found.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
|
||||||
|
$this->line("{$label} Recomputing XP for user #{$userId}...");
|
||||||
|
|
||||||
|
$result = $xp->recalculateStoredProgress($userId, ! $dryRun);
|
||||||
|
$this->table(
|
||||||
|
['Field', 'Stored', 'Computed'],
|
||||||
|
[
|
||||||
|
['xp', $result['previous']['xp'], $result['computed']['xp']],
|
||||||
|
['level', $result['previous']['level'], $result['computed']['level']],
|
||||||
|
['rank', $result['previous']['rank'], $result['computed']['rank']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
if ($syncAchievements) {
|
||||||
|
$pending = $achievements->previewUnlocks($userId);
|
||||||
|
$this->line('Achievements preview: ' . (empty($pending) ? 'no pending unlocks' : implode(', ', $pending)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn('Dry-run: no changes written.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($syncAchievements) {
|
||||||
|
$unlocked = $achievements->checkAchievements($userId);
|
||||||
|
$this->line('Achievements checked: ' . (empty($unlocked) ? 'no new unlocks' : implode(', ', $unlocked)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info($result['changed'] ? "XP updated for user #{$userId}." : "User #{$userId} was already in sync.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recalculateAll(
|
||||||
|
XPService $xp,
|
||||||
|
AchievementService $achievements,
|
||||||
|
int $chunk,
|
||||||
|
bool $dryRun,
|
||||||
|
bool $syncAchievements,
|
||||||
|
): int {
|
||||||
|
$total = DB::table('users')->whereNull('deleted_at')->count();
|
||||||
|
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
|
||||||
|
|
||||||
|
$this->info("{$label} Recomputing XP for {$total} users (chunk={$chunk})...");
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$changed = 0;
|
||||||
|
$pendingAchievementUsers = 0;
|
||||||
|
$pendingAchievementUnlocks = 0;
|
||||||
|
$appliedAchievementUnlocks = 0;
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
DB::table('users')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunk, function ($users) use ($xp, $achievements, $dryRun, $syncAchievements, &$processed, &$changed, &$pendingAchievementUsers, &$pendingAchievementUnlocks, &$appliedAchievementUnlocks, $bar): void {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$result = $xp->recalculateStoredProgress((int) $user->id, ! $dryRun);
|
||||||
|
|
||||||
|
if ($result['changed']) {
|
||||||
|
$changed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($syncAchievements) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$pending = $achievements->previewUnlocks((int) $user->id);
|
||||||
|
if (! empty($pending)) {
|
||||||
|
$pendingAchievementUsers++;
|
||||||
|
$pendingAchievementUnlocks += count($pending);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$unlocked = $achievements->checkAchievements((int) $user->id);
|
||||||
|
$appliedAchievementUnlocks += count($unlocked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$summary = "Done - {$processed} users processed, {$changed} " . ($dryRun ? 'would change.' : 'updated.');
|
||||||
|
if ($syncAchievements) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$summary .= " Achievement preview: {$pendingAchievementUnlocks} pending unlock(s) across {$pendingAchievementUsers} user(s).";
|
||||||
|
} else {
|
||||||
|
$summary .= " Achievements re-checked: {$appliedAchievementUnlocks} unlock(s) applied.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info($summary);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/Console/Commands/RecomputeUserStatsCommand.php
Normal file
147
app/Console/Commands/RecomputeUserStatsCommand.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\RecomputeUserStatsJob;
|
||||||
|
use App\Services\UserStatsService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute user_statistics counters from authoritative source tables.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* # Recompute a single user (live)
|
||||||
|
* php artisan skinbase:recompute-user-stats 42
|
||||||
|
*
|
||||||
|
* # Dry-run for a single user
|
||||||
|
* php artisan skinbase:recompute-user-stats 42 --dry-run
|
||||||
|
*
|
||||||
|
* # Recompute all users in chunks of 500
|
||||||
|
* php artisan skinbase:recompute-user-stats --all --chunk=500
|
||||||
|
*
|
||||||
|
* # Recompute all users via queue (one job per chunk)
|
||||||
|
* php artisan skinbase:recompute-user-stats --all --queue
|
||||||
|
*/
|
||||||
|
class RecomputeUserStatsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:recompute-user-stats
|
||||||
|
{user_id? : The ID of a single user to recompute}
|
||||||
|
{--all : Recompute stats for ALL non-deleted users}
|
||||||
|
{--chunk=1000 : Chunk size when --all is used}
|
||||||
|
{--dry-run : Show what would be written without saving}
|
||||||
|
{--queue : Dispatch recompute jobs to the queue (--all mode only)}';
|
||||||
|
|
||||||
|
protected $description = 'Rebuild user_statistics counters from authoritative source tables';
|
||||||
|
|
||||||
|
public function handle(UserStatsService $statsService): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$all = (bool) $this->option('all');
|
||||||
|
$userId = $this->argument('user_id');
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$queue = (bool) $this->option('queue');
|
||||||
|
|
||||||
|
if ($userId !== null && $all) {
|
||||||
|
$this->error('Provide either a user_id OR --all, not both.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userId !== null) {
|
||||||
|
return $this->recomputeSingle((int) $userId, $statsService, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($all) {
|
||||||
|
return $this->recomputeAll($statsService, $chunk, $dryRun, $queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error('Provide a user_id or use --all.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Single user ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function recomputeSingle(int $userId, UserStatsService $statsService, bool $dryRun): int
|
||||||
|
{
|
||||||
|
$exists = DB::table('users')->where('id', $userId)->exists();
|
||||||
|
if (! $exists) {
|
||||||
|
$this->error("User {$userId} not found.");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
|
||||||
|
$this->line("{$label} Recomputing stats for user #{$userId}…");
|
||||||
|
|
||||||
|
$computed = $statsService->recomputeUser($userId, $dryRun);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($computed as $col => $val) {
|
||||||
|
$rows[] = [$col, $val ?? '(null)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(['Column', 'Value'], $rows);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('Dry-run: no changes written.');
|
||||||
|
} else {
|
||||||
|
$this->info("Stats saved for user #{$userId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── All users ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function recomputeAll(
|
||||||
|
UserStatsService $statsService,
|
||||||
|
int $chunk,
|
||||||
|
bool $dryRun,
|
||||||
|
bool $useQueue
|
||||||
|
): int {
|
||||||
|
$total = DB::table('users')->whereNull('deleted_at')->count();
|
||||||
|
$label = $dryRun ? '[DRY-RUN]' : ($useQueue ? '[QUEUE]' : '[LIVE]');
|
||||||
|
|
||||||
|
$this->info("{$label} Recomputing stats for {$total} users (chunk={$chunk})…");
|
||||||
|
|
||||||
|
if ($useQueue && ! $dryRun) {
|
||||||
|
$dispatched = 0;
|
||||||
|
DB::table('users')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunk, function ($users) use (&$dispatched) {
|
||||||
|
$ids = $users->pluck('id')->all();
|
||||||
|
RecomputeUserStatsJob::dispatch($ids);
|
||||||
|
$dispatched += count($ids);
|
||||||
|
$this->line(" Queued chunk of " . count($ids) . " users (total dispatched: {$dispatched})");
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("Done – {$dispatched} users queued for recompute.");
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$bar = $this->output->createProgressBar($total);
|
||||||
|
$bar->start();
|
||||||
|
|
||||||
|
DB::table('users')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunk, function ($users) use ($statsService, $dryRun, &$processed, $bar) {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$statsService->recomputeUser((int) $user->id, $dryRun);
|
||||||
|
$processed++;
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$suffix = $dryRun ? ' (no changes written – dry-run)' : '';
|
||||||
|
$this->info("Done – {$processed} users recomputed{$suffix}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\IndexArtworkJob;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ReindexRecentPublishedArtworksCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:search-reindex-recent
|
||||||
|
{--hours=72 : Reindex artworks published in the last N hours}
|
||||||
|
{--limit=1000 : Maximum artworks to process in this run}
|
||||||
|
{--id=* : Specific artwork IDs to reindex (overrides --hours window)}
|
||||||
|
{--dry-run : Show candidates without dispatching index jobs}';
|
||||||
|
|
||||||
|
protected $description = 'Reindex recently published public artworks to recover missed search indexing.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$hours = max(1, (int) $this->option('hours'));
|
||||||
|
$limit = max(1, (int) $this->option('limit'));
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', (array) $this->option('id')), static fn (int $id): bool => $id > 0)));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$since = now()->subHours($hours);
|
||||||
|
|
||||||
|
$query = Artwork::query()
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('is_public', true)
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNotNull('published_at');
|
||||||
|
|
||||||
|
if ($ids !== []) {
|
||||||
|
$query->whereIn('id', $ids)->orderBy('id');
|
||||||
|
} else {
|
||||||
|
$query->where('published_at', '>=', $since)
|
||||||
|
->orderByDesc('published_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $query->limit($limit)->get(['id', 'title', 'slug', 'published_at']);
|
||||||
|
|
||||||
|
if ($candidates->isEmpty()) {
|
||||||
|
if ($ids !== []) {
|
||||||
|
$this->line('No matching published artworks found for the provided --id values.');
|
||||||
|
} else {
|
||||||
|
$this->line("No published artworks found in the last {$hours} hour(s).");
|
||||||
|
}
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ids !== []) {
|
||||||
|
$this->info('Found ' . $candidates->count() . ' target artwork(s) by --id.' . ($dryRun ? ' [DRY RUN]' : ''));
|
||||||
|
} else {
|
||||||
|
$this->info("Found {$candidates->count()} artwork(s) published in the last {$hours} hour(s)." . ($dryRun ? ' [DRY RUN]' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($candidates as $artwork) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(" [dry-run] Would reindex #{$artwork->id} ({$artwork->slug})");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
IndexArtworkJob::dispatchSync((int) $artwork->id);
|
||||||
|
$this->line(" Reindexed #{$artwork->id} ({$artwork->slug})");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->info('Done. Recent published artworks were reindexed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal file
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Support\UsernamePolicy;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class RepairLegacyWallzUsersCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:repair-legacy-wallz-users
|
||||||
|
{--chunk=500 : Number of legacy wallz rows to scan per batch}
|
||||||
|
{--legacy-connection=legacy : Legacy database connection name}
|
||||||
|
{--legacy-table=wallz : Legacy table to update}
|
||||||
|
{--artworks-table=artworks : Current DB artworks table name}
|
||||||
|
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
|
||||||
|
{--dry-run : Preview matches and inserts without writing changes}';
|
||||||
|
|
||||||
|
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$legacyConnection = (string) $this->option('legacy-connection');
|
||||||
|
$legacyTable = (string) $this->option('legacy-table');
|
||||||
|
$artworksTable = (string) $this->option('artworks-table');
|
||||||
|
$fixArtworks = (bool) $this->option('fix-artworks');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
|
||||||
|
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('[DRY RUN] No changes will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fixArtworks) {
|
||||||
|
$this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (int) DB::connection($legacyConnection)
|
||||||
|
->table($legacyTable)
|
||||||
|
->where('user_id', 0)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
if (! $fixArtworks) {
|
||||||
|
$this->info('No legacy wallz rows with user_id = 0 were found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}.");
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$updatedRows = 0;
|
||||||
|
$matchedUsers = 0;
|
||||||
|
$createdUsers = 0;
|
||||||
|
$skippedRows = 0;
|
||||||
|
$usernameMap = [];
|
||||||
|
|
||||||
|
DB::connection($legacyConnection)
|
||||||
|
->table($legacyTable)
|
||||||
|
->select(['id', 'uname'])
|
||||||
|
->where('user_id', 0)
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunk, function ($rows) use (
|
||||||
|
&$processed,
|
||||||
|
&$updatedRows,
|
||||||
|
&$matchedUsers,
|
||||||
|
&$createdUsers,
|
||||||
|
&$skippedRows,
|
||||||
|
&$usernameMap,
|
||||||
|
$dryRun,
|
||||||
|
$legacyConnection,
|
||||||
|
$legacyTable
|
||||||
|
) {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
$rawUsername = trim((string) ($row->uname ?? ''));
|
||||||
|
if ($rawUsername === '') {
|
||||||
|
$skippedRows++;
|
||||||
|
$this->warn("Skipping wallz id={$row->id}: uname is empty.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lookupKey = UsernamePolicy::normalize($rawUsername);
|
||||||
|
if ($lookupKey === '') {
|
||||||
|
$skippedRows++;
|
||||||
|
$this->warn("Skipping wallz id={$row->id}: uname normalizes to empty.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_key_exists($lookupKey, $usernameMap)) {
|
||||||
|
$existingUser = $this->findUserByUsername($lookupKey);
|
||||||
|
|
||||||
|
if ($existingUser !== null) {
|
||||||
|
$usernameMap[$lookupKey] = [
|
||||||
|
'user_id' => (int) $existingUser->id,
|
||||||
|
'created' => false,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$usernameMap[$lookupKey] = [
|
||||||
|
'user_id' => $dryRun
|
||||||
|
? 0
|
||||||
|
: $this->createUserForLegacyUsername($rawUsername, $legacyConnection),
|
||||||
|
'created' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $usernameMap[$lookupKey];
|
||||||
|
|
||||||
|
if ($resolved['created']) {
|
||||||
|
$createdUsers++;
|
||||||
|
$usernameMap[$lookupKey]['created'] = false;
|
||||||
|
$resolved['created'] = false;
|
||||||
|
$this->line($dryRun
|
||||||
|
? "[dry] Would create user for uname='{$rawUsername}'"
|
||||||
|
: "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'");
|
||||||
|
} else {
|
||||||
|
$matchedUsers++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$targetUser = $usernameMap[$lookupKey]['user_id'] > 0
|
||||||
|
? (string) $usernameMap[$lookupKey]['user_id']
|
||||||
|
: '<new-user-id>';
|
||||||
|
$this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'");
|
||||||
|
$updatedRows++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$affected = DB::connection($legacyConnection)
|
||||||
|
->table($legacyTable)
|
||||||
|
->where('id', $row->id)
|
||||||
|
->where('user_id', 0)
|
||||||
|
->update([
|
||||||
|
'user_id' => $usernameMap[$lookupKey]['user_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($affected > 0) {
|
||||||
|
$updatedRows += $affected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 'id');
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d',
|
||||||
|
$processed,
|
||||||
|
$updatedRows,
|
||||||
|
$matchedUsers,
|
||||||
|
$createdUsers,
|
||||||
|
$skippedRows
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void
|
||||||
|
{
|
||||||
|
$this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0");
|
||||||
|
|
||||||
|
$total = (int) DB::table($artworksTable)->where('user_id', 0)->count();
|
||||||
|
$this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}.");
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$updated = 0;
|
||||||
|
DB::table($artworksTable)
|
||||||
|
->select(['id'])
|
||||||
|
->where('user_id', 0)
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
$legacyUser = DB::connection($legacyConnection)
|
||||||
|
->table($legacyTable)
|
||||||
|
->where('id', $row->id)
|
||||||
|
->value('user_id');
|
||||||
|
|
||||||
|
$legacyUser = (int) ($legacyUser ?? 0);
|
||||||
|
if ($legacyUser <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}");
|
||||||
|
$updated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$affected = DB::table($artworksTable)
|
||||||
|
->where('id', $row->id)
|
||||||
|
->where('user_id', 0)
|
||||||
|
->update(['user_id' => $legacyUser]);
|
||||||
|
|
||||||
|
if ($affected > 0) {
|
||||||
|
$updated += $affected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 'id');
|
||||||
|
|
||||||
|
$this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function legacyTableExists(string $connection, string $table): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return DB::connection($connection)->getSchemaBuilder()->hasTable($table);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findUserByUsername(string $normalizedUsername): ?object
|
||||||
|
{
|
||||||
|
return DB::table('users')
|
||||||
|
->select(['id', 'username'])
|
||||||
|
->whereRaw('LOWER(username) = ?', [$normalizedUsername])
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int
|
||||||
|
{
|
||||||
|
$username = UsernamePolicy::uniqueCandidate($legacyUsername);
|
||||||
|
$emailLocal = $this->sanitizeEmailLocal($username);
|
||||||
|
$email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org');
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
// Attempt to copy legacy joinDate from the legacy `users` table when available.
|
||||||
|
$legacyJoin = null;
|
||||||
|
try {
|
||||||
|
$legacyJoin = DB::connection($legacyConnection)
|
||||||
|
->table('users')
|
||||||
|
->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)])
|
||||||
|
->value('joinDate');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$legacyJoin = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdAt = $now;
|
||||||
|
if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) {
|
||||||
|
try {
|
||||||
|
$createdAt = Carbon::parse($legacyJoin);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$createdAt = $now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) DB::table('users')->insertGetId([
|
||||||
|
'username' => $username,
|
||||||
|
'username_changed_at' => $now,
|
||||||
|
'name' => $legacyUsername,
|
||||||
|
'email' => $email,
|
||||||
|
'password' => Hash::make(Str::random(64)),
|
||||||
|
'is_active' => true,
|
||||||
|
'needs_password_reset' => true,
|
||||||
|
'role' => 'user',
|
||||||
|
'legacy_password_algo' => null,
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uniqueEmailCandidate(string $email): string
|
||||||
|
{
|
||||||
|
$candidate = strtolower(trim($email));
|
||||||
|
$suffix = 1;
|
||||||
|
|
||||||
|
while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) {
|
||||||
|
$parts = explode('@', $email, 2);
|
||||||
|
$local = $parts[0] ?? 'user';
|
||||||
|
$domain = $parts[1] ?? 'users.skinbase.org';
|
||||||
|
$candidate = $local . '+' . $suffix . '@' . $domain;
|
||||||
|
$suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeEmailLocal(string $value): string
|
||||||
|
{
|
||||||
|
$local = strtolower(trim($value));
|
||||||
|
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
||||||
|
|
||||||
|
return trim($local, '.-') ?: 'user';
|
||||||
|
}
|
||||||
|
}
|
||||||
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal file
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Support\UsernamePolicy;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class RepairTemporaryUsernamesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:repair-temp-usernames
|
||||||
|
{--chunk=500 : Number of users to process per batch}
|
||||||
|
{--dry-run : Preview username changes without writing them}';
|
||||||
|
|
||||||
|
protected $description = 'Replace current users.username values like tmpu% using the users.name field';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('[DRY RUN] No changes will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (int) DB::table('users')
|
||||||
|
->where('username', 'like', 'tmpu%')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
$this->info('No users with temporary tmpu% usernames were found.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$total} users with temporary tmpu% usernames.");
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$updated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
DB::table('users')
|
||||||
|
->select(['id', 'name', 'username'])
|
||||||
|
->where('username', 'like', 'tmpu%')
|
||||||
|
->chunkById($chunk, function ($rows) use (&$processed, &$updated, &$skipped, $dryRun) {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
$sourceName = trim((string) ($row->name ?? ''));
|
||||||
|
if ($sourceName === '') {
|
||||||
|
$skipped++;
|
||||||
|
$this->warn("Skipping user id={$row->id}: name is empty.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = $this->resolveCandidate($sourceName, (int) $row->id);
|
||||||
|
|
||||||
|
if ($candidate === null || strcasecmp($candidate, (string) $row->username) === 0) {
|
||||||
|
$skipped++;
|
||||||
|
$this->warn("Skipping user id={$row->id}: unable to resolve a better username from name='{$sourceName}'.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line("[dry] Would update user id={$row->id} username '{$row->username}' => '{$candidate}'");
|
||||||
|
$updated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$affected = DB::table('users')
|
||||||
|
->where('id', (int) $row->id)
|
||||||
|
->where('username', 'like', 'tmpu%')
|
||||||
|
->update([
|
||||||
|
'username' => $candidate,
|
||||||
|
'username_changed_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($affected > 0) {
|
||||||
|
$updated += $affected;
|
||||||
|
$this->line("[update] user id={$row->id} username '{$row->username}' => '{$candidate}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 'id');
|
||||||
|
|
||||||
|
$this->info(sprintf('Finished. processed=%d updated=%d skipped=%d', $processed, $updated, $skipped));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCandidate(string $sourceName, int $userId): ?string
|
||||||
|
{
|
||||||
|
$base = UsernamePolicy::sanitizeLegacy($sourceName);
|
||||||
|
$min = UsernamePolicy::min();
|
||||||
|
$max = UsernamePolicy::max();
|
||||||
|
|
||||||
|
if ($base === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^tmpu\d+$/i', $base) === 1) {
|
||||||
|
$base = 'user' . $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($base) < $min) {
|
||||||
|
$base = substr($base . $userId, 0, $max);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($base === '' || $base === 'user') {
|
||||||
|
$base = 'user' . $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = substr($base, 0, $max);
|
||||||
|
$suffix = 1;
|
||||||
|
|
||||||
|
while ($this->usernameExists($candidate, $userId) || UsernamePolicy::isReserved($candidate)) {
|
||||||
|
$suffixValue = (string) $suffix;
|
||||||
|
$prefixLen = max(1, $max - strlen($suffixValue));
|
||||||
|
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
|
||||||
|
$suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function usernameExists(string $username, int $ignoreUserId): bool
|
||||||
|
{
|
||||||
|
return DB::table('users')
|
||||||
|
->whereRaw('LOWER(username) = ?', [strtolower($username)])
|
||||||
|
->where('id', '!=', $ignoreUserId)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/Console/Commands/ResetWindowedStatsCommand.php
Normal file
97
app/Console/Commands/ResetWindowedStatsCommand.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* php artisan skinbase:reset-windowed-stats --period=24h|7d
|
||||||
|
*
|
||||||
|
* Resets / recomputes the sliding-window stats columns in artwork_stats:
|
||||||
|
*
|
||||||
|
* views_24h / views_7d
|
||||||
|
* — Zeroed on each reset because we have no per-view event log.
|
||||||
|
* Artworks re-accumulate from the next view event onward.
|
||||||
|
* (Low-traffic reset window: 03:30 means minimal trending disruption.)
|
||||||
|
*
|
||||||
|
* downloads_24h / downloads_7d
|
||||||
|
* — Recomputed accurately from the artwork_downloads event log.
|
||||||
|
* A single bulk UPDATE with a correlated COUNT() is safe here because
|
||||||
|
* it runs once nightly/weekly, not in the hot path.
|
||||||
|
*
|
||||||
|
* Scheduled in routes/console.php:
|
||||||
|
* --period=24h daily at 03:30
|
||||||
|
* --period=7d weekly (Monday) at 03:30
|
||||||
|
*/
|
||||||
|
class ResetWindowedStatsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:reset-windowed-stats
|
||||||
|
{--period=24h : Window to reset: 24h or 7d}';
|
||||||
|
|
||||||
|
protected $description = 'Reset windowed view/download counters in artwork_stats';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$period = (string) $this->option('period');
|
||||||
|
|
||||||
|
if (! in_array($period, ['24h', '7d'], true)) {
|
||||||
|
$this->error("Invalid period '{$period}'. Use 24h or 7d.");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$viewsCol, $downloadsCol, $cutoff] = match ($period) {
|
||||||
|
'24h' => ['views_24h', 'downloads_24h', now()->subDay()],
|
||||||
|
default => ['views_7d', 'downloads_7d', now()->subDays(7)],
|
||||||
|
};
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
// ── 1. Zero the views window column ──────────────────────────────────
|
||||||
|
// We have no per-view event log, so we reset the accumulator.
|
||||||
|
$viewsReset = DB::table('artwork_stats')->update([$viewsCol => 0]);
|
||||||
|
|
||||||
|
// ── 2. Recompute downloads window from the event log ─────────────────
|
||||||
|
// artwork_downloads has created_at, so each row's window is accurate.
|
||||||
|
// Chunked PHP loop avoids MySQL-only functions (GREATEST, INTERVAL)
|
||||||
|
// so this command works in both MySQL (production) and SQLite (tests).
|
||||||
|
$downloadsRecomputed = 0;
|
||||||
|
|
||||||
|
DB::table('artwork_stats')
|
||||||
|
->orderBy('artwork_id')
|
||||||
|
->chunk(1000, function ($rows) use ($downloadsCol, $cutoff, &$downloadsRecomputed): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$count = DB::table('artwork_downloads')
|
||||||
|
->where('artwork_id', $row->artwork_id)
|
||||||
|
->where('created_at', '>=', $cutoff)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
DB::table('artwork_stats')
|
||||||
|
->where('artwork_id', $row->artwork_id)
|
||||||
|
->update([$downloadsCol => max(0, $count)]);
|
||||||
|
|
||||||
|
$downloadsRecomputed++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$elapsed = round(microtime(true) - $start, 2);
|
||||||
|
|
||||||
|
$this->info("Period: {$period}");
|
||||||
|
$this->info(" {$viewsCol}: zeroed {$viewsReset} rows");
|
||||||
|
$this->info(" {$downloadsCol}: recomputed {$downloadsRecomputed} rows ({$elapsed}s)");
|
||||||
|
|
||||||
|
Log::info('ResetWindowedStats complete', [
|
||||||
|
'period' => $period,
|
||||||
|
'views_col' => $viewsCol,
|
||||||
|
'views_rows_reset' => $viewsReset,
|
||||||
|
'downloads_col' => $downloadsCol,
|
||||||
|
'downloads_recomputed' => $downloadsRecomputed,
|
||||||
|
'elapsed_s' => $elapsed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
188
app/Console/Commands/SanitizeContent.php
Normal file
188
app/Console/Commands/SanitizeContent.php
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\ContentSanitizer;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* php artisan skinbase:sanitize-content
|
||||||
|
*
|
||||||
|
* Scans legacy content for unsafe HTML, converts it to Markdown-safe text,
|
||||||
|
* and populates the raw_content / rendered_content columns on artwork_comments.
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* --dry-run Preview changes without writing
|
||||||
|
* --chunk=200 Rows per batch
|
||||||
|
* --table= Limit to one target
|
||||||
|
* --artwork-id= Limit to a single artwork (filters artwork_comments by artwork_id, artworks by id)
|
||||||
|
*/
|
||||||
|
class SanitizeContent extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:sanitize-content
|
||||||
|
{--dry-run : Preview changes without writing to the database}
|
||||||
|
{--chunk=200 : Number of rows per batch}
|
||||||
|
{--table= : Limit scan to a single target (artwork_comments|artworks|forum_posts)}
|
||||||
|
{--artwork-id= : Limit scan to a single artwork ID (skips forum_posts)}';
|
||||||
|
|
||||||
|
protected $description = 'Strip unsafe HTML from legacy content and populate sanitized columns.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* table => [read_col, write_raw_col, write_rendered_col|null]
|
||||||
|
*
|
||||||
|
* For artwork_comments we write two columns; for the others we only sanitize in-place.
|
||||||
|
*/
|
||||||
|
private const TARGETS = [
|
||||||
|
'artwork_comments' => [
|
||||||
|
'read' => 'content',
|
||||||
|
'write_raw' => 'raw_content',
|
||||||
|
'write_rendered' => 'rendered_content',
|
||||||
|
],
|
||||||
|
'artworks' => [
|
||||||
|
'read' => 'description',
|
||||||
|
'write_raw' => 'description',
|
||||||
|
'write_rendered' => null,
|
||||||
|
],
|
||||||
|
'forum_posts' => [
|
||||||
|
'read' => 'content',
|
||||||
|
'write_raw' => 'content',
|
||||||
|
'write_rendered' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$chunk = max(1, (int) $this->option('chunk'));
|
||||||
|
$tableOpt = $this->option('table');
|
||||||
|
$artworkId = $this->option('artwork-id');
|
||||||
|
|
||||||
|
if ($artworkId !== null) {
|
||||||
|
if (! ctype_digit((string) $artworkId) || (int) $artworkId < 1) {
|
||||||
|
$this->error("--artwork-id must be a positive integer. Got: {$artworkId}");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
$artworkId = (int) $artworkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targets = self::TARGETS;
|
||||||
|
if ($tableOpt) {
|
||||||
|
if (! isset($targets[$tableOpt])) {
|
||||||
|
$this->error("Unknown table: {$tableOpt}. Allowed: " . implode(', ', array_keys($targets)));
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
$targets = [$tableOpt => $targets[$tableOpt]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --artwork-id removes forum_posts (no artwork FK) and informs the user.
|
||||||
|
if ($artworkId !== null) {
|
||||||
|
unset($targets['forum_posts']);
|
||||||
|
$this->line("Filtering to artwork <info>#{$artworkId}</info> (forum_posts skipped).");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY-RUN mode — no changes will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalModified = 0;
|
||||||
|
$totalRows = 0;
|
||||||
|
|
||||||
|
foreach ($targets as $table => $def) {
|
||||||
|
$this->line("Processing <info>{$table}</info>…");
|
||||||
|
|
||||||
|
[$modified, $rows] = $this->processTable($table, $def, $chunk, $dryRun, $artworkId);
|
||||||
|
$totalModified += $modified;
|
||||||
|
$totalRows += $rows;
|
||||||
|
|
||||||
|
$this->line(" → {$rows} rows scanned, {$modified} modified.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Summary: {$totalRows} rows, {$totalModified} " . ($dryRun ? 'would be ' : '') . 'modified.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processTable(
|
||||||
|
string $table,
|
||||||
|
array $def,
|
||||||
|
int $chunk,
|
||||||
|
bool $dryRun,
|
||||||
|
?int $artworkId = null
|
||||||
|
): array {
|
||||||
|
$totalModified = 0;
|
||||||
|
$totalRows = 0;
|
||||||
|
|
||||||
|
$readCol = $def['read'];
|
||||||
|
$writeRawCol = $def['write_raw'];
|
||||||
|
$writeRenderedCol = $def['write_rendered'];
|
||||||
|
|
||||||
|
DB::table($table)
|
||||||
|
->whereNotNull($readCol)
|
||||||
|
->when($artworkId !== null, function ($q) use ($table, $artworkId) {
|
||||||
|
// artwork_comments has artwork_id; artworks is filtered by its own PK.
|
||||||
|
$filterCol = $table === 'artwork_comments' ? 'artwork_id' : 'id';
|
||||||
|
$q->where($filterCol, $artworkId);
|
||||||
|
})
|
||||||
|
->orderBy('id')
|
||||||
|
->chunk($chunk, function ($rows) use (
|
||||||
|
$table, $readCol, $writeRawCol, $writeRenderedCol,
|
||||||
|
$dryRun, &$totalModified, &$totalRows
|
||||||
|
) {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$original = $row->$readCol ?? '';
|
||||||
|
$stripped = ContentSanitizer::stripToPlain($original);
|
||||||
|
|
||||||
|
$totalRows++;
|
||||||
|
|
||||||
|
// Detect if content had HTML that we need to clean
|
||||||
|
$hadHtml = $original !== $stripped && preg_match('/<[a-z][^>]*>/i', $original);
|
||||||
|
|
||||||
|
if ($writeRawCol === $readCol && ! $hadHtml) {
|
||||||
|
// Same column, no HTML, skip
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rendered = ContentSanitizer::render($stripped);
|
||||||
|
$totalModified++;
|
||||||
|
|
||||||
|
if ($hadHtml) {
|
||||||
|
$this->line(" [{$table}#{$row->id}] Stripped HTML from content.");
|
||||||
|
Log::info("skinbase:sanitize-content stripped HTML from {$table}#{$row->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$update = [$writeRawCol => $stripped];
|
||||||
|
|
||||||
|
if ($writeRenderedCol) {
|
||||||
|
$update[$writeRenderedCol] = $rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table($table)->where('id', $row->id)->update($update);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also populate rendered_content for rows that have raw_content but no rendered_content
|
||||||
|
if ($writeRenderedCol && ! $dryRun) {
|
||||||
|
DB::table($table)
|
||||||
|
->whereNotNull($writeRawCol)
|
||||||
|
->whereNull($writeRenderedCol)
|
||||||
|
->orderBy('id')
|
||||||
|
->chunk(200, function ($missing) use ($table, $writeRawCol, $writeRenderedCol) {
|
||||||
|
foreach ($missing as $row) {
|
||||||
|
$rendered = ContentSanitizer::render($row->$writeRawCol ?? '');
|
||||||
|
DB::table($table)->where('id', $row->id)->update([
|
||||||
|
$writeRenderedCol => $rendered,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [$totalModified, $totalRows];
|
||||||
|
}
|
||||||
|
}
|
||||||
118
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal file
118
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||||
|
use App\Services\Vision\VectorGatewayClient;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
final class SearchArtworkVectorsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:vectors-search
|
||||||
|
{artwork_id : Source artwork id}
|
||||||
|
{--limit=5 : Number of similar artworks to return}';
|
||||||
|
|
||||||
|
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
|
||||||
|
|
||||||
|
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
|
||||||
|
{
|
||||||
|
if (! $client->isConfigured()) {
|
||||||
|
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworkId = max(1, (int) $this->argument('artwork_id'));
|
||||||
|
$limit = max(1, min((int) $this->option('limit'), 100));
|
||||||
|
|
||||||
|
$artwork = Artwork::query()
|
||||||
|
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||||
|
->find($artworkId);
|
||||||
|
|
||||||
|
if (! $artwork) {
|
||||||
|
$this->error("Artwork {$artworkId} was not found.");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $imageUrl->fromArtwork($artwork);
|
||||||
|
if ($url === null) {
|
||||||
|
$this->error("Artwork {$artworkId} does not have a usable CDN image URL.");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$matches = $client->searchByUrl($url, $limit + 1);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Vector search failed: ' . $e->getMessage());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = collect($matches)
|
||||||
|
->map(fn (array $match): int => (int) $match['id'])
|
||||||
|
->filter(fn (int $id): bool => $id > 0 && $id !== $artworkId)
|
||||||
|
->unique()
|
||||||
|
->take($limit)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($ids === []) {
|
||||||
|
$this->warn('No similar artworks were returned by the vector gateway.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworks = Artwork::query()
|
||||||
|
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->public()
|
||||||
|
->published()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($matches as $match) {
|
||||||
|
$matchId = (int) ($match['id'] ?? 0);
|
||||||
|
if ($matchId <= 0 || $matchId === $artworkId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Artwork|null $matchedArtwork */
|
||||||
|
$matchedArtwork = $artworks->get($matchId);
|
||||||
|
if (! $matchedArtwork) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = $this->primaryCategory($matchedArtwork);
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $matchId,
|
||||||
|
'score' => number_format((float) ($match['score'] ?? 0.0), 4, '.', ''),
|
||||||
|
'title' => (string) $matchedArtwork->title,
|
||||||
|
'content_type' => (string) ($category?->contentType?->name ?? ''),
|
||||||
|
'category' => (string) ($category?->name ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($rows) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rows === []) {
|
||||||
|
$this->warn('The vector gateway returned matches, but none resolved to public published artworks.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(['ID', 'Score', 'Title', 'Content Type', 'Category'], $rows);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function primaryCategory(Artwork $artwork): ?Category
|
||||||
|
{
|
||||||
|
/** @var Category|null $category */
|
||||||
|
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||||
|
|
||||||
|
return $category;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
app/Console/Commands/SeedTagInteractionDemoCommand.php
Normal file
167
app/Console/Commands/SeedTagInteractionDemoCommand.php
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tag;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class SeedTagInteractionDemoCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:seed-tag-interaction-demo
|
||||||
|
{--days=14 : Number of days to generate demo events for}
|
||||||
|
{--per-day=90 : Approximate number of demo events to write per day}
|
||||||
|
{--refresh : Remove existing seeded demo events first}
|
||||||
|
{--force : Allow running outside local/testing environments}';
|
||||||
|
|
||||||
|
protected $description = 'Generate demo tag interaction events for local analytics dashboards and ranking validation';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! app()->environment(['local', 'testing']) && ! $this->option('force')) {
|
||||||
|
$this->error('This command is restricted to local/testing unless --force is provided.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = max(1, min(60, (int) $this->option('days')));
|
||||||
|
$perDay = max(10, min(500, (int) $this->option('per-day')));
|
||||||
|
|
||||||
|
$tags = Tag::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->limit(20)
|
||||||
|
->get(['id', 'name', 'slug', 'usage_count']);
|
||||||
|
|
||||||
|
if ($tags->count() < 2) {
|
||||||
|
$this->error('At least two active tags are required to generate demo interaction data.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transitions = $this->buildTransitionMap($tags);
|
||||||
|
|
||||||
|
if ($this->option('refresh')) {
|
||||||
|
DB::table('tag_interaction_events')
|
||||||
|
->where('meta->seeded_demo', true)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
for ($offset = $days - 1; $offset >= 0; $offset--) {
|
||||||
|
$date = Carbon::today()->subDays($offset);
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
for ($index = 0; $index < $perDay; $index++) {
|
||||||
|
$surface = $this->pickSurface();
|
||||||
|
$sourceTag = $tags->random();
|
||||||
|
$targetTag = $this->pickTargetTag($surface, $sourceTag->slug, $transitions, $tags);
|
||||||
|
$query = in_array($surface, ['search_suggestion', 'rescue_suggestion', 'recent_search'], true)
|
||||||
|
? $this->queryForTag($targetTag)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'event_date' => $date->toDateString(),
|
||||||
|
'event_type' => 'click',
|
||||||
|
'surface' => $surface,
|
||||||
|
'user_id' => null,
|
||||||
|
'session_key' => hash('sha256', 'demo-' . $date->toDateString() . '-' . $index . '-' . $surface),
|
||||||
|
'tag_slug' => $targetTag->slug,
|
||||||
|
'source_tag_slug' => in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)
|
||||||
|
? $sourceTag->slug
|
||||||
|
: null,
|
||||||
|
'query' => $query,
|
||||||
|
'position' => random_int(1, 4),
|
||||||
|
'meta' => json_encode([
|
||||||
|
'seeded_demo' => true,
|
||||||
|
'seeded_at' => $now->toISOString(),
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
'occurred_at' => $date->copy()->setTime(random_int(8, 23), random_int(0, 59), random_int(0, 59)),
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_chunk($rows, 250) as $chunk) {
|
||||||
|
DB::table('tag_interaction_events')->insert($chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->call('analytics:aggregate-tag-interactions', ['--date' => $date->toDateString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Seeded demo tag interaction events for the last {$days} days.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTransitionMap(Collection $tags): array
|
||||||
|
{
|
||||||
|
$pairs = DB::table('artwork_tag as source_pivot')
|
||||||
|
->join('tags as source_tag', 'source_tag.id', '=', 'source_pivot.tag_id')
|
||||||
|
->join('artwork_tag as target_pivot', 'target_pivot.artwork_id', '=', 'source_pivot.artwork_id')
|
||||||
|
->join('tags as target_tag', 'target_tag.id', '=', 'target_pivot.tag_id')
|
||||||
|
->whereIn('source_tag.id', $tags->pluck('id')->all())
|
||||||
|
->whereIn('target_tag.id', $tags->pluck('id')->all())
|
||||||
|
->whereColumn('source_tag.id', '!=', 'target_tag.id')
|
||||||
|
->groupBy('source_tag.slug', 'target_tag.slug')
|
||||||
|
->orderByRaw('COUNT(*) DESC')
|
||||||
|
->get([
|
||||||
|
'source_tag.slug as source_slug',
|
||||||
|
'target_tag.slug as target_slug',
|
||||||
|
DB::raw('COUNT(*) as pair_count'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($pairs as $pair) {
|
||||||
|
$map[$pair->source_slug][] = $pair->target_slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pickSurface(): string
|
||||||
|
{
|
||||||
|
$roll = random_int(1, 100);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$roll <= 32 => 'search_suggestion',
|
||||||
|
$roll <= 46 => 'rescue_suggestion',
|
||||||
|
$roll <= 58 => 'recent_search',
|
||||||
|
$roll <= 80 => 'related_chip',
|
||||||
|
$roll <= 94 => 'related_cluster',
|
||||||
|
default => 'top_companion',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pickTargetTag(string $surface, string $sourceSlug, array $transitions, Collection $tags): object
|
||||||
|
{
|
||||||
|
if (in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)) {
|
||||||
|
$candidateSlugs = $transitions[$sourceSlug] ?? [];
|
||||||
|
if ($candidateSlugs !== []) {
|
||||||
|
$slug = $candidateSlugs[array_rand($candidateSlugs)];
|
||||||
|
return $tags->firstWhere('slug', $slug) ?? $tags->where('slug', '!=', $sourceSlug)->random();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tags->where('slug', '!=', $sourceSlug)->random();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tags->random();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queryForTag(object $tag): string
|
||||||
|
{
|
||||||
|
$name = trim((string) ($tag->name ?? $tag->slug));
|
||||||
|
$options = array_values(array_filter([
|
||||||
|
strtolower($name),
|
||||||
|
strtolower((string) ($tag->slug ?? '')),
|
||||||
|
strtolower(substr($name, 0, max(3, min(strlen($name), 7)))),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return $options[array_rand($options)];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Console/Commands/SyncCountriesCommand.php
Normal file
50
app/Console/Commands/SyncCountriesCommand.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Countries\CountrySyncService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class SyncCountriesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'skinbase:sync-countries
|
||||||
|
{--deactivate-missing : Mark countries missing from the source as inactive}
|
||||||
|
{--no-fallback : Fail instead of using the local fallback dataset when remote fetch fails}';
|
||||||
|
|
||||||
|
protected $description = 'Synchronize ISO 3166 country metadata into the local countries table.';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CountrySyncService $countrySyncService,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$summary = $this->countrySyncService->sync(
|
||||||
|
allowFallback: ! (bool) $this->option('no-fallback'),
|
||||||
|
deactivateMissing: (bool) $this->option('deactivate-missing') ? true : null,
|
||||||
|
);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->error($exception->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Countries synchronized successfully.');
|
||||||
|
$this->line('Source: '.($summary['source'] ?? 'unknown'));
|
||||||
|
$this->line('Fetched: '.(int) ($summary['total_fetched'] ?? 0));
|
||||||
|
$this->line('Inserted: '.(int) ($summary['inserted'] ?? 0));
|
||||||
|
$this->line('Updated: '.(int) ($summary['updated'] ?? 0));
|
||||||
|
$this->line('Skipped: '.(int) ($summary['skipped'] ?? 0));
|
||||||
|
$this->line('Invalid: '.(int) ($summary['invalid'] ?? 0));
|
||||||
|
$this->line('Deactivated: '.(int) ($summary['deactivated'] ?? 0));
|
||||||
|
$this->line('Backfilled users: '.(int) ($summary['backfilled_users'] ?? 0));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Console/Commands/WarmPostTrendingCommand.php
Normal file
28
app/Console/Commands/WarmPostTrendingCommand.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Posts\PostTrendingService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warms the post trending cache so requests are fast.
|
||||||
|
* Scheduled every 2 minutes to match the cache TTL.
|
||||||
|
*/
|
||||||
|
class WarmPostTrendingCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'posts:warm-trending';
|
||||||
|
protected $description = 'Refresh the post trending feed cache.';
|
||||||
|
|
||||||
|
public function __construct(private PostTrendingService $trending)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$ids = $this->trending->refresh();
|
||||||
|
$this->info('Trending feed cache refreshed. ' . count($ids) . ' post(s) ranked.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,26 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
|||||||
use App\Console\Commands\ImportLegacyUsers;
|
use App\Console\Commands\ImportLegacyUsers;
|
||||||
use App\Console\Commands\ImportCategories;
|
use App\Console\Commands\ImportCategories;
|
||||||
use App\Console\Commands\MigrateFeaturedWorks;
|
use App\Console\Commands\MigrateFeaturedWorks;
|
||||||
|
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||||
|
use App\Console\Commands\IndexArtworkVectorsCommand;
|
||||||
|
use App\Console\Commands\SearchArtworkVectorsCommand;
|
||||||
|
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||||
|
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||||
|
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||||
|
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
||||||
|
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||||
|
use App\Console\Commands\AiTagArtworksCommand;
|
||||||
|
use App\Console\Commands\SyncCountriesCommand;
|
||||||
|
use App\Console\Commands\CompareFeedAbCommand;
|
||||||
|
use App\Console\Commands\RecalculateTrendingCommand;
|
||||||
|
use App\Console\Commands\RecalculateRankingsCommand;
|
||||||
|
use App\Console\Commands\MetricsSnapshotHourlyCommand;
|
||||||
|
use App\Console\Commands\RecalculateHeatCommand;
|
||||||
|
use App\Jobs\UpdateLeaderboardsJob;
|
||||||
|
use App\Jobs\RankComputeArtworkScoresJob;
|
||||||
|
use App\Jobs\RankBuildListsJob;
|
||||||
|
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||||
|
use App\Console\Commands\PublishScheduledArtworksCommand;
|
||||||
|
|
||||||
class Kernel extends ConsoleKernel
|
class Kernel extends ConsoleKernel
|
||||||
{
|
{
|
||||||
@@ -16,9 +36,30 @@ class Kernel extends ConsoleKernel
|
|||||||
*/
|
*/
|
||||||
protected $commands = [
|
protected $commands = [
|
||||||
ImportLegacyUsers::class,
|
ImportLegacyUsers::class,
|
||||||
|
\App\Console\Commands\EnforceUsernamePolicy::class,
|
||||||
ImportCategories::class,
|
ImportCategories::class,
|
||||||
MigrateFeaturedWorks::class,
|
MigrateFeaturedWorks::class,
|
||||||
|
\App\Console\Commands\AvatarsMigrate::class,
|
||||||
|
\App\Console\Commands\AvatarsBulkUpdate::class,
|
||||||
\App\Console\Commands\ResetAllUserPasswords::class,
|
\App\Console\Commands\ResetAllUserPasswords::class,
|
||||||
|
CleanupUploadsCommand::class,
|
||||||
|
PublishScheduledArtworksCommand::class,
|
||||||
|
BackfillArtworkEmbeddingsCommand::class,
|
||||||
|
IndexArtworkVectorsCommand::class,
|
||||||
|
SearchArtworkVectorsCommand::class,
|
||||||
|
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||||
|
AggregateFeedAnalyticsCommand::class,
|
||||||
|
AggregateTagInteractionAnalyticsCommand::class,
|
||||||
|
SeedTagInteractionDemoCommand::class,
|
||||||
|
EvaluateFeedWeightsCommand::class,
|
||||||
|
CompareFeedAbCommand::class,
|
||||||
|
AiTagArtworksCommand::class,
|
||||||
|
SyncCountriesCommand::class,
|
||||||
|
\App\Console\Commands\MigrateFollows::class,
|
||||||
|
RecalculateTrendingCommand::class,
|
||||||
|
RecalculateRankingsCommand::class,
|
||||||
|
MetricsSnapshotHourlyCommand::class,
|
||||||
|
RecalculateHeatCommand::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +67,62 @@ class Kernel extends ConsoleKernel
|
|||||||
*/
|
*/
|
||||||
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
||||||
{
|
{
|
||||||
// $schedule->command('inspire')->hourly();
|
$schedule->command('uploads:cleanup')->dailyAt('03:00');
|
||||||
|
|
||||||
|
// Publish artworks whose scheduled publish_at has passed
|
||||||
|
$schedule->command('artworks:publish-scheduled')
|
||||||
|
->everyMinute()
|
||||||
|
->name('publish-scheduled-artworks')
|
||||||
|
->withoutOverlapping(2) // prevent overlap up to 2 minutes
|
||||||
|
->runInBackground();
|
||||||
|
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||||
|
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||||
|
$schedule->command('analytics:aggregate-tag-interactions')->dailyAt('03:30');
|
||||||
|
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
|
||||||
|
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
|
||||||
|
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
|
||||||
|
|
||||||
|
// ── Ranking system (rank_v1) ────────────────────────────────────────
|
||||||
|
// Step 1: compute per-artwork scores every hour at :05
|
||||||
|
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
|
||||||
|
// Step 2: build ranked lists every hour at :15 (after scores are ready)
|
||||||
|
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground();
|
||||||
|
|
||||||
|
// ── Ranking Engine V2 — runs every 30 min ──────────────────────────
|
||||||
|
$schedule->command('nova:recalculate-rankings --sync-rank-scores')
|
||||||
|
->everyThirtyMinutes()
|
||||||
|
->name('ranking-v2')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
$schedule->job(new UpdateLeaderboardsJob)
|
||||||
|
->hourlyAt(20)
|
||||||
|
->name('leaderboards-refresh')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
|
||||||
|
// Step 1: snapshot metric totals every hour at :00
|
||||||
|
$schedule->command('nova:metrics-snapshot-hourly')
|
||||||
|
->hourly()
|
||||||
|
->name('metrics-snapshot-hourly')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
// Step 2: recalculate heat scores every 15 minutes
|
||||||
|
$schedule->command('nova:recalculate-heat')
|
||||||
|
->everyFifteenMinutes()
|
||||||
|
->name('recalculate-heat')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
// Step 3: prune old snapshots daily at 04:00
|
||||||
|
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
|
||||||
|
->dailyAt('04:00');
|
||||||
|
|
||||||
|
$schedule->command('skinbase:sync-countries')
|
||||||
|
->monthlyOn(1, '03:40')
|
||||||
|
->name('sync-countries')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
14
app/DTOs/Artworks/ArtworkDraftResult.php
Normal file
14
app/DTOs/Artworks/ArtworkDraftResult.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Artworks;
|
||||||
|
|
||||||
|
final class ArtworkDraftResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $artworkId,
|
||||||
|
public readonly string $status
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/DTOs/Uploads/UploadChunkResult.php
Normal file
17
app/DTOs/Uploads/UploadChunkResult.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadChunkResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $sessionId,
|
||||||
|
public readonly string $status,
|
||||||
|
public readonly int $receivedBytes,
|
||||||
|
public readonly int $totalBytes,
|
||||||
|
public readonly int $progress
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/DTOs/Uploads/UploadInitResult.php
Normal file
15
app/DTOs/Uploads/UploadInitResult.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadInitResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $sessionId,
|
||||||
|
public readonly string $token,
|
||||||
|
public readonly string $status
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/DTOs/Uploads/UploadScanResult.php
Normal file
24
app/DTOs/Uploads/UploadScanResult.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadScanResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly bool $ok,
|
||||||
|
public readonly string $reason
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clean(): self
|
||||||
|
{
|
||||||
|
return new self(true, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infected(string $reason): self
|
||||||
|
{
|
||||||
|
return new self(false, $reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/DTOs/Uploads/UploadSessionData.php
Normal file
22
app/DTOs/Uploads/UploadSessionData.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
final class UploadSessionData
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $id,
|
||||||
|
public readonly int $userId,
|
||||||
|
public readonly string $tempPath,
|
||||||
|
public readonly string $status,
|
||||||
|
public readonly string $ip,
|
||||||
|
public readonly CarbonImmutable $createdAt,
|
||||||
|
public readonly int $progress,
|
||||||
|
public readonly ?string $failureReason
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/DTOs/Uploads/UploadStoredFile.php
Normal file
23
app/DTOs/Uploads/UploadStoredFile.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadStoredFile
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $path,
|
||||||
|
public readonly int $size,
|
||||||
|
public readonly string $extension
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromPath(string $path): self
|
||||||
|
{
|
||||||
|
$size = is_file($path) ? (int) filesize($path) : 0;
|
||||||
|
$extension = (string) pathinfo($path, PATHINFO_EXTENSION);
|
||||||
|
|
||||||
|
return new self($path, $size, $extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/DTOs/Uploads/UploadValidatedFile.php
Normal file
14
app/DTOs/Uploads/UploadValidatedFile.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadValidatedFile
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly UploadValidationResult $validation,
|
||||||
|
public readonly ?string $hash
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/DTOs/Uploads/UploadValidationResult.php
Normal file
28
app/DTOs/Uploads/UploadValidationResult.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadValidationResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly bool $ok,
|
||||||
|
public readonly string $reason,
|
||||||
|
public readonly ?int $width,
|
||||||
|
public readonly ?int $height,
|
||||||
|
public readonly ?string $mime,
|
||||||
|
public readonly ?int $size
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ok(int $width, int $height, string $mime, int $size): self
|
||||||
|
{
|
||||||
|
return new self(true, '', $width, $height, $mime, $size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fail(string $reason, ?int $width = null, ?int $height = null, ?string $mime = null, ?int $size = null): self
|
||||||
|
{
|
||||||
|
return new self(false, $reason, $width, $height, $mime, $size);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/DTOs/UserRecoProfileDTO.php
Normal file
94
app/DTOs/UserRecoProfileDTO.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight value object representing a user's recommendation preference profile.
|
||||||
|
*
|
||||||
|
* Built by UserPreferenceBuilder from signals:
|
||||||
|
* - favourited artworks (+3)
|
||||||
|
* - awards given (+5)
|
||||||
|
* - creator follows (+2 for their tags)
|
||||||
|
* - own uploads (category bias)
|
||||||
|
*
|
||||||
|
* Cached in `user_reco_profiles` with a configurable TTL (default 6 hours).
|
||||||
|
*/
|
||||||
|
final class UserRecoProfileDTO
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $topTagSlugs Top tag slugs by weighted score (up to 20)
|
||||||
|
* @param array<int, string> $topCategorySlugs Top category slugs (up to 5)
|
||||||
|
* @param array<int, int> $strongCreatorIds Followed creator user IDs (up to 50)
|
||||||
|
* @param array<string, float> $tagWeights Tag slug → normalised weight (0–1)
|
||||||
|
* @param array<string, float> $categoryWeights Category slug → normalised weight
|
||||||
|
* @param array<int, string> $dislikedTagSlugs Future: blocked/hidden tag slugs
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $topTagSlugs = [],
|
||||||
|
public readonly array $topCategorySlugs = [],
|
||||||
|
public readonly array $strongCreatorIds = [],
|
||||||
|
public readonly array $tagWeights = [],
|
||||||
|
public readonly array $categoryWeights = [],
|
||||||
|
public readonly array $dislikedTagSlugs = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the user has enough signals to drive personalised recommendations.
|
||||||
|
*/
|
||||||
|
public function hasSignals(): bool
|
||||||
|
{
|
||||||
|
return $this->topTagSlugs !== [] || $this->strongCreatorIds !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the normalised tag weight for a given slug (0.0 if unknown).
|
||||||
|
*/
|
||||||
|
public function tagWeight(string $slug): float
|
||||||
|
{
|
||||||
|
return (float) ($this->tagWeights[$slug] ?? 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the creator is in the user's strong-follow list.
|
||||||
|
*/
|
||||||
|
public function followsCreator(int $userId): bool
|
||||||
|
{
|
||||||
|
return in_array($userId, $this->strongCreatorIds, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise for storage in the DB / Redis cache.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'top_tags' => $this->topTagSlugs,
|
||||||
|
'top_categories' => $this->topCategorySlugs,
|
||||||
|
'strong_creators' => $this->strongCreatorIds,
|
||||||
|
'tag_weights' => $this->tagWeights,
|
||||||
|
'category_weights' => $this->categoryWeights,
|
||||||
|
'disliked_tags' => $this->dislikedTagSlugs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-hydrate from a stored array (e.g. from the DB JSON column).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
topTagSlugs: (array) ($data['top_tags'] ?? []),
|
||||||
|
topCategorySlugs: (array) ($data['top_categories'] ?? []),
|
||||||
|
strongCreatorIds: array_map('intval', (array) ($data['strong_creators'] ?? [])),
|
||||||
|
tagWeights: array_map('floatval', (array) ($data['tag_weights'] ?? [])),
|
||||||
|
categoryWeights: array_map('floatval', (array) ($data['category_weights'] ?? [])),
|
||||||
|
dislikedTagSlugs: (array) ($data['disliked_tags'] ?? []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Enums/ReactionType.php
Normal file
63
app/Enums/ReactionType.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reaction slugs used in the database.
|
||||||
|
* Emoji are only used for display — slugs are stored.
|
||||||
|
*/
|
||||||
|
enum ReactionType: string
|
||||||
|
{
|
||||||
|
case ThumbsUp = 'thumbs_up';
|
||||||
|
case Heart = 'heart';
|
||||||
|
case Fire = 'fire';
|
||||||
|
case Laugh = 'laugh';
|
||||||
|
case Clap = 'clap';
|
||||||
|
case Wow = 'wow';
|
||||||
|
|
||||||
|
/** Return the display emoji for this reaction. */
|
||||||
|
public function emoji(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ThumbsUp => '👍',
|
||||||
|
self::Heart => '❤️',
|
||||||
|
self::Fire => '🔥',
|
||||||
|
self::Laugh => '😂',
|
||||||
|
self::Clap => '👏',
|
||||||
|
self::Wow => '😮',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable label. */
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ThumbsUp => 'Like',
|
||||||
|
self::Heart => 'Love',
|
||||||
|
self::Fire => 'Fire',
|
||||||
|
self::Laugh => 'Haha',
|
||||||
|
self::Clap => 'Clap',
|
||||||
|
self::Wow => 'Wow',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All valid slugs — used for validation. */
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full UI payload for the frontend. */
|
||||||
|
public static function asMap(): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach (self::cases() as $case) {
|
||||||
|
$map[$case->value] = [
|
||||||
|
'slug' => $case->value,
|
||||||
|
'emoji' => $case->emoji(),
|
||||||
|
'label' => $case->label(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Events/Achievements/AchievementCheckRequested.php
Normal file
15
app/Events/Achievements/AchievementCheckRequested.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events\Achievements;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class AchievementCheckRequested
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public readonly int $userId) {}
|
||||||
|
}
|
||||||
15
app/Events/Achievements/UserXpUpdated.php
Normal file
15
app/Events/Achievements/UserXpUpdated.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Events\Achievements;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class UserXpUpdated
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public readonly int $userId) {}
|
||||||
|
}
|
||||||
50
app/Events/ConversationUpdated.php
Normal file
50
app/Events/ConversationUpdated.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Conversation;
|
||||||
|
use App\Services\Messaging\MessagingPayloadFactory;
|
||||||
|
use App\Services\Messaging\UnreadCounterService;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ConversationUpdated implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public bool $afterCommit = true;
|
||||||
|
public string $queue;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $userId,
|
||||||
|
public Conversation $conversation,
|
||||||
|
public string $reason,
|
||||||
|
) {
|
||||||
|
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PrivateChannel('user.' . $this->userId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'conversation.updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event' => 'conversation.updated',
|
||||||
|
'reason' => $this->reason,
|
||||||
|
'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId),
|
||||||
|
'summary' => [
|
||||||
|
'unread_total' => app(UnreadCounterService::class)->totalUnreadForUser($this->userId),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Events/MessageCreated.php
Normal file
51
app/Events/MessageCreated.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Conversation;
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Services\Messaging\MessagingPayloadFactory;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class MessageCreated implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public bool $afterCommit = true;
|
||||||
|
public string $queue;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Conversation $conversation,
|
||||||
|
public Message $message,
|
||||||
|
int $originUserId,
|
||||||
|
) {
|
||||||
|
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||||
|
|
||||||
|
if ($originUserId === (int) $message->sender_id) {
|
||||||
|
$this->dontBroadcastToCurrentUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PrivateChannel('conversation.' . $this->conversation->id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'message.created';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event' => 'message.created',
|
||||||
|
'conversation_id' => (int) $this->conversation->id,
|
||||||
|
'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Events/MessageDeleted.php
Normal file
45
app/Events/MessageDeleted.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Message;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class MessageDeleted implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public bool $afterCommit = true;
|
||||||
|
public string $queue;
|
||||||
|
|
||||||
|
public function __construct(public Message $message)
|
||||||
|
{
|
||||||
|
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||||
|
$this->dontBroadcastToCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PrivateChannel('conversation.' . $this->message->conversation_id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'message.deleted';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event' => 'message.deleted',
|
||||||
|
'conversation_id' => (int) $this->message->conversation_id,
|
||||||
|
'message_id' => (int) $this->message->id,
|
||||||
|
'uuid' => (string) $this->message->uuid,
|
||||||
|
'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Events/MessageRead.php
Normal file
51
app/Events/MessageRead.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Conversation;
|
||||||
|
use App\Models\ConversationParticipant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Messaging\MessagingPayloadFactory;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class MessageRead implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public bool $afterCommit = true;
|
||||||
|
public string $queue;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Conversation $conversation,
|
||||||
|
public ConversationParticipant $participant,
|
||||||
|
public User $reader,
|
||||||
|
) {
|
||||||
|
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||||
|
$this->dontBroadcastToCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PrivateChannel('conversation.' . $this->conversation->id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'message.read';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event' => 'message.read',
|
||||||
|
'conversation_id' => (int) $this->conversation->id,
|
||||||
|
'user' => app(MessagingPayloadFactory::class)->userSummary($this->reader),
|
||||||
|
'last_read_message_id' => $this->participant->last_read_message_id ? (int) $this->participant->last_read_message_id : null,
|
||||||
|
'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Events/MessageUpdated.php
Normal file
44
app/Events/MessageUpdated.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Services\Messaging\MessagingPayloadFactory;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PrivateChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class MessageUpdated implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public bool $afterCommit = true;
|
||||||
|
public string $queue;
|
||||||
|
|
||||||
|
public function __construct(public Message $message)
|
||||||
|
{
|
||||||
|
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||||
|
$this->dontBroadcastToCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PrivateChannel('conversation.' . $this->message->conversation_id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'message.updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event' => 'message.updated',
|
||||||
|
'conversation_id' => (int) $this->message->conversation_id,
|
||||||
|
'message' => app(MessagingPayloadFactory::class)->message($this->message),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Events/Posts/ArtworkShared.php
Normal file
20
app/Events/Posts/ArtworkShared.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events\Posts;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class ArtworkShared
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly Post $post,
|
||||||
|
public readonly Artwork $artwork,
|
||||||
|
public readonly User $sharer,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
20
app/Events/Posts/PostCommented.php
Normal file
20
app/Events/Posts/PostCommented.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events\Posts;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\PostComment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class PostCommented
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly Post $post,
|
||||||
|
public readonly PostComment $comment,
|
||||||
|
public readonly User $commenter,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
47
app/Events/TypingStarted.php
Normal file
47
app/Events/TypingStarted.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Messaging\MessagingPayloadFactory;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PresenceChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class TypingStarted implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public bool $afterCommit = true;
|
||||||
|
public string $queue;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $conversationId,
|
||||||
|
public User $user,
|
||||||
|
) {
|
||||||
|
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||||
|
$this->dontBroadcastToCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PresenceChannel('conversation.' . $this->conversationId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'typing.started';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event' => 'typing.started',
|
||||||
|
'conversation_id' => $this->conversationId,
|
||||||
|
'user' => app(MessagingPayloadFactory::class)->userSummary($this->user),
|
||||||
|
'expires_in_ms' => (int) config('messaging.typing.ttl_seconds', 8) * 1000,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Events/TypingStopped.php
Normal file
46
app/Events/TypingStopped.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Messaging\MessagingPayloadFactory;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Broadcasting\PresenceChannel;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class TypingStopped implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public bool $afterCommit = true;
|
||||||
|
public string $queue;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $conversationId,
|
||||||
|
public User $user,
|
||||||
|
) {
|
||||||
|
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||||
|
$this->dontBroadcastToCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [new PresenceChannel('conversation.' . $this->conversationId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'typing.stopped';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event' => 'typing.stopped',
|
||||||
|
'conversation_id' => $this->conversationId,
|
||||||
|
'user' => app(MessagingPayloadFactory::class)->userSummary($this->user),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class FeedPerformanceReportController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'limit' => ['nullable', 'integer', 'min:1', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
|
||||||
|
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||||
|
$limit = (int) ($validated['limit'] ?? 100);
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = DB::table('feed_daily_metrics')
|
||||||
|
->selectRaw('algo_version, source')
|
||||||
|
->selectRaw('SUM(impressions) as impressions')
|
||||||
|
->selectRaw('SUM(clicks) as clicks')
|
||||||
|
->selectRaw('SUM(saves) as saves')
|
||||||
|
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
|
||||||
|
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
|
||||||
|
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
|
||||||
|
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
|
||||||
|
->whereBetween('metric_date', [$from, $to])
|
||||||
|
->groupBy('algo_version', 'source')
|
||||||
|
->orderBy('algo_version')
|
||||||
|
->orderBy('source')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byAlgoSource = $rows->map(static function ($row): array {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
$saves = (int) ($row->saves ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
'source' => (string) $row->source,
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'saves' => $saves,
|
||||||
|
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
|
||||||
|
'save_rate' => round($clicks > 0 ? $saves / $clicks : 0.0, 6),
|
||||||
|
'dwell_buckets' => [
|
||||||
|
'0_5' => (int) ($row->dwell_0_5 ?? 0),
|
||||||
|
'5_30' => (int) ($row->dwell_5_30 ?? 0),
|
||||||
|
'30_120' => (int) ($row->dwell_30_120 ?? 0),
|
||||||
|
'120_plus' => (int) ($row->dwell_120_plus ?? 0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
$topClickedArtworks = DB::table('feed_events as e')
|
||||||
|
->leftJoin('artworks as a', 'a.id', '=', 'e.artwork_id')
|
||||||
|
->selectRaw('e.algo_version')
|
||||||
|
->selectRaw('e.source')
|
||||||
|
->selectRaw('e.artwork_id')
|
||||||
|
->selectRaw('a.title as artwork_title')
|
||||||
|
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->whereBetween('e.event_date', [$from, $to])
|
||||||
|
->groupBy('e.algo_version', 'e.source', 'e.artwork_id', 'a.title')
|
||||||
|
->get()
|
||||||
|
->map(static function ($row): array {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
'source' => (string) $row->source,
|
||||||
|
'artwork_id' => (int) $row->artwork_id,
|
||||||
|
'artwork_title' => (string) ($row->artwork_title ?? ''),
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->sort(static function (array $a, array $b): int {
|
||||||
|
$clickCompare = $b['clicks'] <=> $a['clicks'];
|
||||||
|
if ($clickCompare !== 0) {
|
||||||
|
return $clickCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $b['ctr'] <=> $a['ctr'];
|
||||||
|
})
|
||||||
|
->take($limit)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'meta' => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'generated_at' => now()->toISOString(),
|
||||||
|
'limit' => $limit,
|
||||||
|
],
|
||||||
|
'by_algo_source' => $byAlgoSource,
|
||||||
|
'top_clicked_artworks' => $topClickedArtworks,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Report;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class ModerationReportQueueController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$status = (string) $request->query('status', 'open');
|
||||||
|
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
|
||||||
|
|
||||||
|
$items = Report::query()
|
||||||
|
->with('reporter:id,username')
|
||||||
|
->where('status', $status)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->paginate(30);
|
||||||
|
|
||||||
|
return response()->json($items);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class SimilarArtworkReportController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'limit' => ['nullable', 'integer', 'min:1', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
|
||||||
|
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||||
|
$limit = (int) ($validated['limit'] ?? 100);
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$byAlgoRows = DB::table('similar_artwork_events')
|
||||||
|
->selectRaw('algo_version')
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->whereBetween('event_date', [$from, $to])
|
||||||
|
->groupBy('algo_version')
|
||||||
|
->orderBy('algo_version')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byAlgo = $byAlgoRows->map(static function ($row): array {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'ctr' => round($ctr, 6),
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
$pairRows = DB::table('similar_artwork_events as e')
|
||||||
|
->leftJoin('artworks as source', 'source.id', '=', 'e.source_artwork_id')
|
||||||
|
->leftJoin('artworks as similar', 'similar.id', '=', 'e.similar_artwork_id')
|
||||||
|
->selectRaw('e.algo_version')
|
||||||
|
->selectRaw('e.source_artwork_id')
|
||||||
|
->selectRaw('e.similar_artwork_id')
|
||||||
|
->selectRaw('source.title as source_title')
|
||||||
|
->selectRaw('similar.title as similar_title')
|
||||||
|
->selectRaw("SUM(CASE WHEN e.event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN e.event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->whereBetween('e.event_date', [$from, $to])
|
||||||
|
->whereNotNull('e.similar_artwork_id')
|
||||||
|
->groupBy('e.algo_version', 'e.source_artwork_id', 'e.similar_artwork_id', 'source.title', 'similar.title')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$topSimilarities = $pairRows
|
||||||
|
->map(static function ($row): array {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
'source_artwork_id' => (int) $row->source_artwork_id,
|
||||||
|
'source_title' => (string) ($row->source_title ?? ''),
|
||||||
|
'similar_artwork_id' => (int) $row->similar_artwork_id,
|
||||||
|
'similar_title' => (string) ($row->similar_title ?? ''),
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'ctr' => round($ctr, 6),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->sort(function (array $a, array $b): int {
|
||||||
|
$ctrCompare = $b['ctr'] <=> $a['ctr'];
|
||||||
|
if ($ctrCompare !== 0) {
|
||||||
|
return $ctrCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clickCompare = $b['clicks'] <=> $a['clicks'];
|
||||||
|
if ($clickCompare !== 0) {
|
||||||
|
return $clickCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $b['impressions'] <=> $a['impressions'];
|
||||||
|
})
|
||||||
|
->take($limit)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'meta' => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'generated_at' => now()->toISOString(),
|
||||||
|
'limit' => $limit,
|
||||||
|
],
|
||||||
|
'by_algo_version' => $byAlgo,
|
||||||
|
'top_similarities' => $topSimilarities,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Analytics\TagInteractionReportService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class TagInteractionReportController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TagInteractionReportService $reportService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
|
||||||
|
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||||
|
$limit = (int) ($validated['limit'] ?? 15);
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->reportService->buildReport($from, $to, $limit);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'meta' => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'limit' => $limit,
|
||||||
|
'generated_at' => now()->toISOString(),
|
||||||
|
'latest_aggregated_date' => $report['latest_aggregated_date'],
|
||||||
|
],
|
||||||
|
'overview' => $report['overview'],
|
||||||
|
'daily_clicks' => $report['daily_clicks'],
|
||||||
|
'by_surface' => $report['by_surface'],
|
||||||
|
'top_tags' => $report['top_tags'],
|
||||||
|
'top_queries' => $report['top_queries'],
|
||||||
|
'top_transitions' => $report['top_transitions'],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Upload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class UploadModerationController extends Controller
|
||||||
|
{
|
||||||
|
public function pending(): JsonResponse
|
||||||
|
{
|
||||||
|
$uploads = Upload::query()
|
||||||
|
->where('status', 'draft')
|
||||||
|
->where('moderation_status', 'pending')
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get([
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'processing_state',
|
||||||
|
'title',
|
||||||
|
'preview_path',
|
||||||
|
'created_at',
|
||||||
|
'moderation_status',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $uploads,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$upload = Upload::query()->find($id);
|
||||||
|
|
||||||
|
if (! $upload) {
|
||||||
|
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload->moderation_status = 'approved';
|
||||||
|
$upload->moderated_at = now();
|
||||||
|
$upload->moderated_by = (int) $request->user()->id;
|
||||||
|
$upload->moderation_note = $request->input('note');
|
||||||
|
$upload->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'id' => (string) $upload->id,
|
||||||
|
'moderation_status' => (string) $upload->moderation_status,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$upload = Upload::query()->find($id);
|
||||||
|
|
||||||
|
if (! $upload) {
|
||||||
|
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload->moderation_status = 'rejected';
|
||||||
|
$upload->status = 'rejected';
|
||||||
|
$upload->processing_state = 'rejected';
|
||||||
|
$upload->moderated_at = now();
|
||||||
|
$upload->moderated_by = (int) $request->user()->id;
|
||||||
|
$upload->moderation_note = (string) $request->input('note', '');
|
||||||
|
$upload->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'id' => (string) $upload->id,
|
||||||
|
'status' => (string) $upload->status,
|
||||||
|
'processing_state' => (string) $upload->processing_state,
|
||||||
|
'moderation_status' => (string) $upload->moderation_status,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
153
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal file
153
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\UsernamePolicy;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class UsernameApprovalController extends Controller
|
||||||
|
{
|
||||||
|
public function pending(): JsonResponse
|
||||||
|
{
|
||||||
|
$rows = DB::table('username_approval_requests')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get([
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'requested_username',
|
||||||
|
'context',
|
||||||
|
'similar_to',
|
||||||
|
'payload',
|
||||||
|
'created_at',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $rows], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(int $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$row = DB::table('username_approval_requests')->where('id', $id)->first();
|
||||||
|
if (! $row) {
|
||||||
|
return response()->json(['message' => 'Request not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $row->status !== 'pending') {
|
||||||
|
return response()->json(['message' => 'Request is not pending.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::beginTransaction();
|
||||||
|
try {
|
||||||
|
DB::table('username_approval_requests')
|
||||||
|
->where('id', $id)
|
||||||
|
->update([
|
||||||
|
'status' => 'approved',
|
||||||
|
'reviewed_by' => (int) $request->user()->id,
|
||||||
|
'reviewed_at' => now(),
|
||||||
|
'review_note' => (string) $request->input('note', ''),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ((string) $row->context === 'profile_update' && ! empty($row->user_id)) {
|
||||||
|
$this->applyProfileRename((int) $row->user_id, (string) $row->requested_username);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'id' => $id,
|
||||||
|
'status' => 'approved',
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(int $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$affected = DB::table('username_approval_requests')
|
||||||
|
->where('id', $id)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update([
|
||||||
|
'status' => 'rejected',
|
||||||
|
'reviewed_by' => (int) $request->user()->id,
|
||||||
|
'reviewed_at' => now(),
|
||||||
|
'review_note' => (string) $request->input('note', ''),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($affected === 0) {
|
||||||
|
return response()->json(['message' => 'Request not found or not pending.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'id' => $id,
|
||||||
|
'status' => 'rejected',
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyProfileRename(int $userId, string $requestedUsername): void
|
||||||
|
{
|
||||||
|
$user = User::query()->find($userId);
|
||||||
|
if (! $user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requested = UsernamePolicy::normalize($requestedUsername);
|
||||||
|
if ($requested === '') {
|
||||||
|
throw new \RuntimeException('Requested username is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = User::query()
|
||||||
|
->whereRaw('LOWER(username) = ?', [$requested])
|
||||||
|
->where('id', '!=', $userId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
throw new \RuntimeException('Requested username is already taken.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$old = UsernamePolicy::normalize((string) ($user->username ?? ''));
|
||||||
|
if ($old === $requested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->username = $requested;
|
||||||
|
$user->username_changed_at = now();
|
||||||
|
if (Schema::hasColumn('users', 'last_username_change_at')) {
|
||||||
|
$user->last_username_change_at = now();
|
||||||
|
}
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
if ($old !== '') {
|
||||||
|
DB::table('username_history')->insert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'old_username' => $old,
|
||||||
|
'changed_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('username_redirects')->updateOrInsert(
|
||||||
|
['old_username' => $old],
|
||||||
|
[
|
||||||
|
'new_username' => $requested,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/Http/Controllers/Api/ArtworkAwardController.php
Normal file
132
app/Http/Controllers/Api/ArtworkAwardController.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ArtworkAward;
|
||||||
|
use App\Services\ArtworkAwardService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class ArtworkAwardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ArtworkAwardService $service
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/artworks/{id}/award
|
||||||
|
* Award the artwork with a medal.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$artwork = Artwork::findOrFail($id);
|
||||||
|
|
||||||
|
$this->authorize('award', [ArtworkAward::class, $artwork]);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$award = $this->service->award($artwork, $user, $data['medal']);
|
||||||
|
|
||||||
|
// Record activity event
|
||||||
|
try {
|
||||||
|
\App\Models\ActivityEvent::record(
|
||||||
|
actorId: $user->id,
|
||||||
|
type: \App\Models\ActivityEvent::TYPE_AWARD,
|
||||||
|
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||||
|
targetId: $artwork->id,
|
||||||
|
meta: ['medal' => $data['medal']],
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
$this->buildPayload($artwork->id, $user->id),
|
||||||
|
201
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/artworks/{id}/award
|
||||||
|
* Change an existing award medal.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$artwork = Artwork::findOrFail($id);
|
||||||
|
|
||||||
|
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->authorize('change', $existingAward);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$award = $this->service->changeAward($artwork, $user, $data['medal']);
|
||||||
|
|
||||||
|
return response()->json($this->buildPayload($artwork->id, $user->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/artworks/{id}/award
|
||||||
|
* Remove the user's award for this artwork.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$artwork = Artwork::findOrFail($id);
|
||||||
|
|
||||||
|
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->authorize('remove', $existingAward);
|
||||||
|
|
||||||
|
$this->service->removeAward($artwork, $user);
|
||||||
|
|
||||||
|
return response()->json($this->buildPayload($artwork->id, $user->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/artworks/{id}/awards
|
||||||
|
* Return award stats + viewer's current award.
|
||||||
|
*/
|
||||||
|
public function show(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::findOrFail($id);
|
||||||
|
|
||||||
|
return response()->json($this->buildPayload($artwork->id, $request->user()?->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// All authorization is delegated to ArtworkAwardPolicy via $this->authorize().
|
||||||
|
|
||||||
|
private function buildPayload(int $artworkId, ?int $userId): array
|
||||||
|
{
|
||||||
|
$stat = \App\Models\ArtworkAwardStat::find($artworkId);
|
||||||
|
|
||||||
|
$userAward = $userId
|
||||||
|
? ArtworkAward::where('artwork_id', $artworkId)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->value('medal')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'awards' => [
|
||||||
|
'gold' => $stat?->gold_count ?? 0,
|
||||||
|
'silver' => $stat?->silver_count ?? 0,
|
||||||
|
'bronze' => $stat?->bronze_count ?? 0,
|
||||||
|
'score' => $stat?->score_total ?? 0,
|
||||||
|
],
|
||||||
|
'viewer_award' => $userAward,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
295
app/Http/Controllers/Api/ArtworkCommentController.php
Normal file
295
app/Http/Controllers/Api/ArtworkCommentController.php
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserMention;
|
||||||
|
use App\Notifications\ArtworkCommentedNotification;
|
||||||
|
use App\Notifications\ArtworkMentionedNotification;
|
||||||
|
use App\Services\ContentSanitizer;
|
||||||
|
use App\Support\AvatarUrl;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Artwork comment CRUD.
|
||||||
|
*
|
||||||
|
* POST /api/artworks/{artworkId}/comments → store
|
||||||
|
* PUT /api/artworks/{artworkId}/comments/{id} → update (own comment)
|
||||||
|
* DELETE /api/artworks/{artworkId}/comments/{id} → delete (own or admin)
|
||||||
|
* GET /api/artworks/{artworkId}/comments → list (paginated)
|
||||||
|
*/
|
||||||
|
class ArtworkCommentController extends Controller
|
||||||
|
{
|
||||||
|
private const MAX_LENGTH = 10_000;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// List
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function index(Request $request, int $artworkId): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||||
|
|
||||||
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
|
$perPage = 20;
|
||||||
|
|
||||||
|
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
|
||||||
|
$comments = ArtworkComment::with([
|
||||||
|
'user', 'user.profile',
|
||||||
|
'approvedReplies',
|
||||||
|
])
|
||||||
|
->where('artwork_id', $artwork->id)
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNull('parent_id')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate($perPage, ['*'], 'page', $page);
|
||||||
|
|
||||||
|
$userId = $request->user()?->id;
|
||||||
|
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $items,
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $comments->currentPage(),
|
||||||
|
'last_page' => $comments->lastPage(),
|
||||||
|
'total' => $comments->total(),
|
||||||
|
'per_page' => $comments->perPage(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Store
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function store(Request $request, int $artworkId): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||||
|
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$raw = $request->input('content');
|
||||||
|
$parentId = $request->input('parent_id');
|
||||||
|
|
||||||
|
// If replying, validate parent belongs to same artwork and is approved
|
||||||
|
if ($parentId) {
|
||||||
|
$parent = ArtworkComment::where('artwork_id', $artwork->id)
|
||||||
|
->where('is_approved', true)
|
||||||
|
->find($parentId);
|
||||||
|
|
||||||
|
if (! $parent) {
|
||||||
|
return response()->json([
|
||||||
|
'errors' => ['parent_id' => ['The comment you are replying to is no longer available.']],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate markdown-lite content
|
||||||
|
$errors = ContentSanitizer::validate($raw);
|
||||||
|
if ($errors) {
|
||||||
|
return response()->json(['errors' => ['content' => $errors]], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rendered = ContentSanitizer::render($raw);
|
||||||
|
|
||||||
|
$comment = ArtworkComment::create([
|
||||||
|
'artwork_id' => $artwork->id,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'content' => $raw, // legacy column (plain text fallback)
|
||||||
|
'raw_content' => $raw,
|
||||||
|
'rendered_content' => $rendered,
|
||||||
|
'is_approved' => true, // auto-approve; extend with moderation as needed
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Bust the comments cache for this user's 'all' feed
|
||||||
|
Cache::forget('comments.latest.all.page1');
|
||||||
|
|
||||||
|
$comment->load(['user', 'user.profile']);
|
||||||
|
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
|
||||||
|
|
||||||
|
// Record activity event (fire-and-forget; never break the response)
|
||||||
|
try {
|
||||||
|
\App\Models\ActivityEvent::record(
|
||||||
|
actorId: $request->user()->id,
|
||||||
|
type: \App\Models\ActivityEvent::TYPE_COMMENT,
|
||||||
|
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||||
|
targetId: $artwork->id,
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Update
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function update(Request $request, int $artworkId, int $commentId): JsonResponse
|
||||||
|
{
|
||||||
|
$comment = ArtworkComment::where('artwork_id', $artworkId)
|
||||||
|
->findOrFail($commentId);
|
||||||
|
|
||||||
|
Gate::authorize('update', $comment);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$raw = $request->input('content');
|
||||||
|
$errors = ContentSanitizer::validate($raw);
|
||||||
|
if ($errors) {
|
||||||
|
return response()->json(['errors' => ['content' => $errors]], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rendered = ContentSanitizer::render($raw);
|
||||||
|
|
||||||
|
$comment->update([
|
||||||
|
'content' => $raw,
|
||||||
|
'raw_content' => $raw,
|
||||||
|
'rendered_content' => $rendered,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::forget('comments.latest.all.page1');
|
||||||
|
|
||||||
|
$comment->load(['user', 'user.profile']);
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse
|
||||||
|
{
|
||||||
|
$comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId);
|
||||||
|
|
||||||
|
Gate::authorize('delete', $comment);
|
||||||
|
|
||||||
|
$comment->delete();
|
||||||
|
Cache::forget('comments.latest.all.page1');
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Comment deleted.'], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
|
||||||
|
{
|
||||||
|
$user = $c->user;
|
||||||
|
$userId = (int) ($c->user_id ?? 0);
|
||||||
|
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'id' => $c->id,
|
||||||
|
'parent_id' => $c->parent_id,
|
||||||
|
'raw_content' => $c->raw_content ?? $c->content,
|
||||||
|
'rendered_content' => $this->renderCommentContent($c),
|
||||||
|
'created_at' => $c->created_at?->toIso8601String(),
|
||||||
|
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||||
|
'can_edit' => $currentUserId === $userId,
|
||||||
|
'can_delete' => $currentUserId === $userId,
|
||||||
|
'user' => [
|
||||||
|
'id' => $userId,
|
||||||
|
'username' => $user?->username,
|
||||||
|
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||||
|
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||||
|
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||||
|
'level' => (int) ($user?->level ?? 1),
|
||||||
|
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
|
||||||
|
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
||||||
|
} elseif ($includeReplies && $c->relationLoaded('replies')) {
|
||||||
|
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
|
||||||
|
} else {
|
||||||
|
$data['replies'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderCommentContent(ArtworkComment $comment): string
|
||||||
|
{
|
||||||
|
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
|
||||||
|
$renderedContent = $comment->rendered_content;
|
||||||
|
|
||||||
|
if (! is_string($renderedContent) || trim($renderedContent) === '') {
|
||||||
|
$renderedContent = $rawContent !== ''
|
||||||
|
? ContentSanitizer::render($rawContent)
|
||||||
|
: nl2br(e(strip_tags((string) ($comment->content ?? ''))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContentSanitizer::sanitizeRenderedHtml(
|
||||||
|
$renderedContent,
|
||||||
|
$this->commentAuthorCanPublishLinks($comment)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commentAuthorCanPublishLinks(ArtworkComment $comment): bool
|
||||||
|
{
|
||||||
|
$level = (int) ($comment->user?->level ?? 1);
|
||||||
|
$rank = strtolower((string) ($comment->user?->rank ?? 'Newbie'));
|
||||||
|
|
||||||
|
return $level > 1 && $rank !== 'newbie';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
|
||||||
|
{
|
||||||
|
$notifiedUserIds = [];
|
||||||
|
$creatorId = (int) ($artwork->user_id ?? 0);
|
||||||
|
|
||||||
|
if ($creatorId > 0 && $creatorId !== (int) $actor->id) {
|
||||||
|
$creator = User::query()->find($creatorId);
|
||||||
|
if ($creator) {
|
||||||
|
$creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
||||||
|
$notifiedUserIds[] = (int) $creator->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parentId) {
|
||||||
|
$parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0);
|
||||||
|
if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) {
|
||||||
|
$parentUser = User::query()->find($parentUserId);
|
||||||
|
if ($parentUser) {
|
||||||
|
$parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
||||||
|
$notifiedUserIds[] = (int) $parentUser->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
User::query()
|
||||||
|
->whereIn(
|
||||||
|
'id',
|
||||||
|
UserMention::query()
|
||||||
|
->where('comment_id', (int) $comment->id)
|
||||||
|
->pluck('mentioned_user_id')
|
||||||
|
->map(fn ($id) => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->all()
|
||||||
|
)
|
||||||
|
->get()
|
||||||
|
->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void {
|
||||||
|
if ((int) $mentionedUser->id === (int) $actor->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Artworks\ArtworkCreateRequest;
|
||||||
use App\Http\Resources\ArtworkListResource;
|
use App\Http\Resources\ArtworkListResource;
|
||||||
use App\Http\Resources\ArtworkResource;
|
use App\Http\Resources\ArtworkResource;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
|
use App\Services\Artworks\ArtworkDraftService;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class ArtworkController extends Controller
|
class ArtworkController extends Controller
|
||||||
{
|
{
|
||||||
@@ -17,6 +20,32 @@ class ArtworkController extends Controller
|
|||||||
$this->service = $service;
|
$this->service = $service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/artworks
|
||||||
|
* Creates a draft artwork placeholder for the upload pipeline.
|
||||||
|
*/
|
||||||
|
public function store(ArtworkCreateRequest $request, ArtworkDraftService $drafts)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$categoryId = isset($data['category']) && ctype_digit((string) $data['category'])
|
||||||
|
? (int) $data['category']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$result = $drafts->createDraft(
|
||||||
|
(int) $user->id,
|
||||||
|
(string) $data['title'],
|
||||||
|
isset($data['description']) ? (string) $data['description'] : null,
|
||||||
|
$categoryId
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'artwork_id' => $result->artworkId,
|
||||||
|
'status' => $result->status,
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/artworks/{slug}
|
* GET /api/v1/artworks/{slug}
|
||||||
* Returns a single public artwork resource by slug.
|
* Returns a single public artwork resource by slug.
|
||||||
|
|||||||
130
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal file
130
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Services\ArtworkStatsService;
|
||||||
|
use App\Services\ThumbnailPresenter;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/art/{id}/download
|
||||||
|
*
|
||||||
|
* Records a download event and returns the full-resolution download URL.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* 1. Validates the artwork is public and published.
|
||||||
|
* 2. Inserts a row in artwork_downloads (artwork_id, user_id, ip, user_agent).
|
||||||
|
* 3. Increments artwork_stats.downloads + forwards to creator stats.
|
||||||
|
* 4. Returns {"ok": true, "url": "<download_url>"} so the frontend can
|
||||||
|
* trigger the actual browser download.
|
||||||
|
*
|
||||||
|
* The frontend fires this POST on click, then uses the returned URL to
|
||||||
|
* trigger the file download (or falls back to the pre-resolved URL it
|
||||||
|
* already has).
|
||||||
|
*/
|
||||||
|
final class ArtworkDownloadController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ArtworkStatsService $stats) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::public()
|
||||||
|
->published()
|
||||||
|
->with(['user:id'])
|
||||||
|
->where('id', $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $artwork) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the download event — non-blocking, errors are swallowed.
|
||||||
|
$this->recordDownload($request, $artwork);
|
||||||
|
|
||||||
|
// Increment counters — deferred via Redis when available.
|
||||||
|
try {
|
||||||
|
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Stats failure must never interrupt the download.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the highest-resolution download URL available.
|
||||||
|
$url = $this->resolveDownloadUrl($artwork);
|
||||||
|
|
||||||
|
// Build a user-friendly download filename: "title-slug.file_ext"
|
||||||
|
$ext = $artwork->file_ext ?: $artwork->thumb_ext ?: 'webp';
|
||||||
|
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id;
|
||||||
|
$filename = $slug . '.' . $ext;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'url' => $url,
|
||||||
|
'filename' => $filename,
|
||||||
|
'size' => (int) ($artwork->file_size ?? 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a row in artwork_downloads.
|
||||||
|
* Uses a raw insert for the binary(16) IP column.
|
||||||
|
* Silently ignores failures (analytics should never break user flow).
|
||||||
|
*/
|
||||||
|
private function recordDownload(Request $request, Artwork $artwork): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$ip = $request->ip() ?? '0.0.0.0';
|
||||||
|
$bin = @inet_pton($ip);
|
||||||
|
|
||||||
|
DB::table('artwork_downloads')->insert([
|
||||||
|
'artwork_id' => $artwork->id,
|
||||||
|
'user_id' => $request->user()?->id,
|
||||||
|
'ip' => $bin !== false ? $bin : null,
|
||||||
|
'ip_address' => mb_substr((string) $ip, 0, 45),
|
||||||
|
'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024),
|
||||||
|
'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535),
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Analytics failure must never interrupt the download.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the original full-resolution CDN URL.
|
||||||
|
*
|
||||||
|
* Originals are stored at: {cdn}/original/{h1}/{h2}/{hash}.{file_ext}
|
||||||
|
* h1 = first 2 chars of hash, h2 = next 2 chars, filename = full hash + file_ext.
|
||||||
|
* Falls back to XL → LG → MD thumbnail when hash is unavailable.
|
||||||
|
*/
|
||||||
|
private function resolveDownloadUrl(Artwork $artwork): string
|
||||||
|
{
|
||||||
|
$hash = $artwork->hash ?? null;
|
||||||
|
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
|
||||||
|
|
||||||
|
if (!empty($hash)) {
|
||||||
|
$h = strtolower(preg_replace('/[^a-f0-9]/', '', $hash));
|
||||||
|
$h1 = substr($h, 0, 2);
|
||||||
|
$h2 = substr($h, 2, 2);
|
||||||
|
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
|
||||||
|
|
||||||
|
return sprintf('%s/original/%s/%s/%s.%s', $cdn, $h1, $h2, $h, $ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: best available thumbnail size
|
||||||
|
foreach (['xl', 'lg', 'md'] as $size) {
|
||||||
|
$thumb = ThumbnailPresenter::present($artwork, $size);
|
||||||
|
if (!empty($thumb['url'])) {
|
||||||
|
return (string) $thumb['url'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
270
app/Http/Controllers/Api/ArtworkInteractionController.php
Normal file
270
app/Http/Controllers/Api/ArtworkInteractionController.php
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Events\Achievements\AchievementCheckRequested;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Notifications\ArtworkLikedNotification;
|
||||||
|
use App\Services\FollowService;
|
||||||
|
use App\Services\UserStatsService;
|
||||||
|
use App\Services\XPService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class ArtworkInteractionController extends Controller
|
||||||
|
{
|
||||||
|
public function bookmark(Request $request, int $artworkId): JsonResponse
|
||||||
|
{
|
||||||
|
$this->toggleSimple(
|
||||||
|
request: $request,
|
||||||
|
table: 'artwork_bookmarks',
|
||||||
|
keyColumns: ['user_id', 'artwork_id'],
|
||||||
|
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||||
|
insertPayload: ['created_at' => now(), 'updated_at' => now()],
|
||||||
|
requiredTable: 'artwork_bookmarks'
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function favorite(Request $request, int $artworkId): JsonResponse
|
||||||
|
{
|
||||||
|
$state = $request->boolean('state', true);
|
||||||
|
|
||||||
|
$changed = $this->toggleSimple(
|
||||||
|
request: $request,
|
||||||
|
table: 'artwork_favourites',
|
||||||
|
keyColumns: ['user_id', 'artwork_id'],
|
||||||
|
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||||
|
insertPayload: ['created_at' => now(), 'updated_at' => now()],
|
||||||
|
requiredTable: 'artwork_favourites'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->syncArtworkStats($artworkId);
|
||||||
|
|
||||||
|
// Update creator's favorites_received_count
|
||||||
|
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||||
|
if ($creatorId) {
|
||||||
|
$svc = app(UserStatsService::class);
|
||||||
|
if ($state && $changed) {
|
||||||
|
$svc->incrementFavoritesReceived($creatorId);
|
||||||
|
$svc->setLastActiveAt((int) $request->user()->id);
|
||||||
|
|
||||||
|
// Record activity event (new favourite only)
|
||||||
|
try {
|
||||||
|
\App\Models\ActivityEvent::record(
|
||||||
|
actorId: (int) $request->user()->id,
|
||||||
|
type: \App\Models\ActivityEvent::TYPE_FAVORITE,
|
||||||
|
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||||
|
targetId: $artworkId,
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
} elseif (! $state && $changed) {
|
||||||
|
$svc->decrementFavoritesReceived($creatorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function like(Request $request, int $artworkId): JsonResponse
|
||||||
|
{
|
||||||
|
$changed = $this->toggleSimple(
|
||||||
|
request: $request,
|
||||||
|
table: 'artwork_likes',
|
||||||
|
keyColumns: ['user_id', 'artwork_id'],
|
||||||
|
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||||
|
insertPayload: ['created_at' => now(), 'updated_at' => now()],
|
||||||
|
requiredTable: 'artwork_likes'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->syncArtworkStats($artworkId);
|
||||||
|
|
||||||
|
if ($request->boolean('state', true) && $changed) {
|
||||||
|
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||||
|
$actorId = (int) $request->user()->id;
|
||||||
|
if ($creatorId > 0 && $creatorId !== $actorId) {
|
||||||
|
app(XPService::class)->awardArtworkLikeReceived($creatorId, $artworkId, $actorId);
|
||||||
|
$creator = \App\Models\User::query()->find($creatorId);
|
||||||
|
$artwork = Artwork::query()->find($artworkId);
|
||||||
|
if ($creator && $artwork) {
|
||||||
|
$creator->notify(new ArtworkLikedNotification($artwork, $request->user()));
|
||||||
|
}
|
||||||
|
event(new AchievementCheckRequested($creatorId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function report(Request $request, int $artworkId): JsonResponse
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('artwork_reports')) {
|
||||||
|
return response()->json(['message' => 'Reporting unavailable'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'reason' => ['nullable', 'string', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('artwork_reports')->updateOrInsert(
|
||||||
|
[
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'reporter_user_id' => (int) $request->user()->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'reason' => trim((string) ($data['reason'] ?? '')) ?: null,
|
||||||
|
'reported_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'reported' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function follow(Request $request, int $userId): JsonResponse
|
||||||
|
{
|
||||||
|
$actorId = (int) $request->user()->id;
|
||||||
|
|
||||||
|
if ($actorId === $userId) {
|
||||||
|
return response()->json(['message' => 'Cannot follow yourself'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$svc = app(FollowService::class);
|
||||||
|
$state = $request->has('state')
|
||||||
|
? $request->boolean('state')
|
||||||
|
: ! $request->isMethod('delete');
|
||||||
|
|
||||||
|
if ($state) {
|
||||||
|
$svc->follow($actorId, $userId);
|
||||||
|
} else {
|
||||||
|
$svc->unfollow($actorId, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'is_following' => $state,
|
||||||
|
'followers_count' => $svc->followersCount($userId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/artworks/{id}/share — record a share event (Phase 2 tracking).
|
||||||
|
*/
|
||||||
|
public function share(Request $request, int $artworkId): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (Schema::hasTable('artwork_shares')) {
|
||||||
|
DB::table('artwork_shares')->insert([
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'user_id' => $request->user()?->id,
|
||||||
|
'platform' => $data['platform'],
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toggleSimple(
|
||||||
|
Request $request,
|
||||||
|
string $table,
|
||||||
|
array $keyColumns,
|
||||||
|
array $keyValues,
|
||||||
|
array $insertPayload,
|
||||||
|
string $requiredTable
|
||||||
|
): bool {
|
||||||
|
if (! Schema::hasTable($requiredTable)) {
|
||||||
|
abort(422, 'Interaction unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $request->boolean('state', true);
|
||||||
|
|
||||||
|
$query = DB::table($table);
|
||||||
|
foreach ($keyColumns as $column) {
|
||||||
|
$query->where($column, $keyValues[$column]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state) {
|
||||||
|
if (! $query->exists()) {
|
||||||
|
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return $query->delete() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncArtworkStats(int $artworkId): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('artwork_stats')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$favorites = Schema::hasTable('artwork_favourites')
|
||||||
|
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$likes = Schema::hasTable('artwork_likes')
|
||||||
|
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
DB::table('artwork_stats')->updateOrInsert(
|
||||||
|
['artwork_id' => $artworkId],
|
||||||
|
[
|
||||||
|
'favorites' => $favorites,
|
||||||
|
'rating_count' => $likes,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function statusPayload(int $viewerId, int $artworkId): array
|
||||||
|
{
|
||||||
|
$isBookmarked = Schema::hasTable('artwork_bookmarks')
|
||||||
|
? DB::table('artwork_bookmarks')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||||
|
: false;
|
||||||
|
|
||||||
|
$isFavorited = Schema::hasTable('artwork_favourites')
|
||||||
|
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||||
|
: false;
|
||||||
|
|
||||||
|
$isLiked = Schema::hasTable('artwork_likes')
|
||||||
|
? DB::table('artwork_likes')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||||
|
: false;
|
||||||
|
|
||||||
|
$favorites = Schema::hasTable('artwork_favourites')
|
||||||
|
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$bookmarks = Schema::hasTable('artwork_bookmarks')
|
||||||
|
? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$likes = Schema::hasTable('artwork_likes')
|
||||||
|
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'is_bookmarked' => $isBookmarked,
|
||||||
|
'is_favorited' => $isFavorited,
|
||||||
|
'is_liked' => $isLiked,
|
||||||
|
'stats' => [
|
||||||
|
'bookmarks' => $bookmarks,
|
||||||
|
'favorites' => $favorites,
|
||||||
|
'likes' => $likes,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Http/Controllers/Api/ArtworkNavigationController.php
Normal file
79
app/Http/Controllers/Api/ArtworkNavigationController.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\ArtworkResource;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ArtworkNavigationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /api/artworks/navigation/{id}
|
||||||
|
*
|
||||||
|
* Returns prev/next published artworks by the same author.
|
||||||
|
*/
|
||||||
|
public function neighbors(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::published()
|
||||||
|
->select(['id', 'user_id', 'title', 'slug'])
|
||||||
|
->find($id);
|
||||||
|
|
||||||
|
if (! $artwork) {
|
||||||
|
return response()->json([
|
||||||
|
'prev_id' => null, 'next_id' => null,
|
||||||
|
'prev_url' => null, 'next_url' => null,
|
||||||
|
'prev_slug' => null, 'next_slug' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = Artwork::published()
|
||||||
|
->select(['id', 'title', 'slug'])
|
||||||
|
->where('user_id', $artwork->user_id);
|
||||||
|
|
||||||
|
$prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first();
|
||||||
|
$next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first();
|
||||||
|
|
||||||
|
// Infinite loop: wrap around when reaching the first or last artwork
|
||||||
|
if (! $prev) {
|
||||||
|
$prev = (clone $scope)->where('id', '!=', $id)->orderByDesc('id')->first();
|
||||||
|
}
|
||||||
|
if (! $next) {
|
||||||
|
$next = (clone $scope)->where('id', '!=', $id)->orderBy('id')->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
$prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null;
|
||||||
|
$nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'prev_id' => $prev?->id,
|
||||||
|
'next_id' => $next?->id,
|
||||||
|
'prev_url' => $prev ? url('/art/' . $prev->id . '/' . $prevSlug) : null,
|
||||||
|
'next_url' => $next ? url('/art/' . $next->id . '/' . $nextSlug) : null,
|
||||||
|
'prev_slug' => $prevSlug,
|
||||||
|
'next_slug' => $nextSlug,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/artworks/{id}/page
|
||||||
|
*
|
||||||
|
* Returns full artwork resource by numeric ID for client-side (no-reload) navigation.
|
||||||
|
*/
|
||||||
|
public function pageData(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
|
||||||
|
->published()
|
||||||
|
->find($id);
|
||||||
|
|
||||||
|
if (! $artwork) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resource = (new ArtworkResource($artwork))->toArray(request());
|
||||||
|
|
||||||
|
return response()->json($resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
165
app/Http/Controllers/Api/ArtworkTagController.php
Normal file
165
app/Http/Controllers/Api/ArtworkTagController.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Artworks\ArtworkTagsStoreRequest;
|
||||||
|
use App\Http\Requests\Artworks\ArtworkTagsUpdateRequest;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Services\TagService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use App\Jobs\AutoTagArtworkJob;
|
||||||
|
|
||||||
|
final class ArtworkTagController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TagService $tags,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::query()->findOrFail($id);
|
||||||
|
$this->authorizeOrNotFound(request()->user(), $artwork);
|
||||||
|
|
||||||
|
$queueConnection = (string) config('queue.default', 'sync');
|
||||||
|
$visionEnabled = (bool) config('vision.enabled', true);
|
||||||
|
|
||||||
|
$queuedCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
|
if (in_array($queueConnection, ['database', 'redis'], true)) {
|
||||||
|
try {
|
||||||
|
$queuedCount = (int) DB::table('jobs')
|
||||||
|
->where('payload', 'like', '%AutoTagArtworkJob%')
|
||||||
|
->where('payload', 'like', '%' . $artwork->id . '%')
|
||||||
|
->count();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$queuedCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$failedCount = (int) DB::table('failed_jobs')
|
||||||
|
->where('payload', 'like', '%AutoTagArtworkJob%')
|
||||||
|
->where('payload', 'like', '%' . $artwork->id . '%')
|
||||||
|
->count();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$failedCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$triggered = false;
|
||||||
|
$shouldTrigger = request()->boolean('trigger', false);
|
||||||
|
if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
|
||||||
|
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
|
||||||
|
$triggered = true;
|
||||||
|
$queuedCount = max(1, $queuedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = $artwork->tags()
|
||||||
|
->select('tags.id', 'tags.name', 'tags.slug')
|
||||||
|
->withPivot(['source', 'confidence'])
|
||||||
|
->orderByDesc('artwork_tag.confidence')
|
||||||
|
->get()
|
||||||
|
->map(static function ($tag): array {
|
||||||
|
$source = (string) ($tag->pivot->source ?? 'manual');
|
||||||
|
return [
|
||||||
|
'id' => (int) $tag->id,
|
||||||
|
'name' => (string) $tag->name,
|
||||||
|
'slug' => (string) $tag->slug,
|
||||||
|
'source' => $source,
|
||||||
|
'confidence' => (float) ($tag->pivot->confidence ?? 0),
|
||||||
|
'is_ai' => $source === 'ai',
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'vision_enabled' => $visionEnabled,
|
||||||
|
'tags' => $tags,
|
||||||
|
'ai_tags' => $tags->where('is_ai', true)->values(),
|
||||||
|
'debug' => [
|
||||||
|
'queue_connection' => $queueConnection,
|
||||||
|
'queued_jobs' => $queuedCount,
|
||||||
|
'failed_jobs' => $failedCount,
|
||||||
|
'triggered' => $triggered,
|
||||||
|
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
|
||||||
|
'total_tag_count' => (int) $tags->count(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(int $id, ArtworkTagsStoreRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::query()->findOrFail($id);
|
||||||
|
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->tags->attachUserTags($artwork, $payload['tags']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true], Response::HTTP_CREATED);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$ref = (string) Str::uuid();
|
||||||
|
logger()->error('Artwork tag attach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Unable to update tags right now.',
|
||||||
|
'ref' => $ref,
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, ArtworkTagsUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::query()->findOrFail($id);
|
||||||
|
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->tags->syncTags($artwork, $payload['tags']);
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$ref = (string) Str::uuid();
|
||||||
|
logger()->error('Artwork tag sync failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Unable to update tags right now.',
|
||||||
|
'ref' => $ref,
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(int $id, Tag $tag): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::query()->findOrFail($id);
|
||||||
|
$this->authorizeOrNotFound(request()->user(), $artwork);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->tags->detachTags($artwork, [$tag->id]);
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$ref = (string) Str::uuid();
|
||||||
|
logger()->error('Artwork tag detach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'user_id' => request()->user()?->id, 'exception' => $e]);
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Unable to update tags right now.',
|
||||||
|
'ref' => $ref,
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeOrNotFound($user, Artwork $artwork): void
|
||||||
|
{
|
||||||
|
if (! $user) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->can('updateTags', $artwork)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Http/Controllers/Api/ArtworkViewController.php
Normal file
76
app/Http/Controllers/Api/ArtworkViewController.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Services\ArtworkStatsService;
|
||||||
|
use App\Services\XPService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/art/{id}/view
|
||||||
|
*
|
||||||
|
* Fire-and-forget view tracker.
|
||||||
|
*
|
||||||
|
* Deduplication strategy (layered):
|
||||||
|
* 1. Session key (`art_viewed.{id}`) — prevents double-counts within the
|
||||||
|
* same browser session (survives page reloads).
|
||||||
|
* 2. Route throttle (5 per 10 minutes per IP+artwork) — catches bots that
|
||||||
|
* don't send session cookies.
|
||||||
|
*
|
||||||
|
* The frontend should additionally guard with sessionStorage so it only
|
||||||
|
* calls this endpoint once per page load.
|
||||||
|
*/
|
||||||
|
final class ArtworkViewController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ArtworkStatsService $stats,
|
||||||
|
private readonly XPService $xp,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::public()
|
||||||
|
->published()
|
||||||
|
->where('id', $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $artwork) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionKey = 'art_viewed.' . $id;
|
||||||
|
|
||||||
|
// Already counted this session — return early without touching the DB.
|
||||||
|
if ($request->hasSession() && $request->session()->has($sessionKey)) {
|
||||||
|
return response()->json(['ok' => true, 'counted' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write persistent event log (auth user_id or null for guests).
|
||||||
|
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
|
||||||
|
|
||||||
|
// Defer to Redis when available, fall back to direct DB increment.
|
||||||
|
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
|
||||||
|
|
||||||
|
$viewerId = $request->user()?->id;
|
||||||
|
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
|
||||||
|
$this->xp->awardArtworkViewReceived(
|
||||||
|
(int) $artwork->user_id,
|
||||||
|
(int) $artwork->id,
|
||||||
|
$viewerId,
|
||||||
|
(string) $request->ip(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark this session so the artwork is not counted again.
|
||||||
|
if ($request->hasSession()) {
|
||||||
|
$request->session()->put($sessionKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'counted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,14 @@ class BrowseController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
|
$perPage = $this->resolvePerPage($request);
|
||||||
|
$sort = (string) $request->get('sort', 'latest');
|
||||||
|
|
||||||
$paginator = $this->service->browsePublicArtworks($perPage);
|
$paginator = $this->service->browsePublicArtworks($perPage, $sort);
|
||||||
|
$paginator->appends([
|
||||||
|
'limit' => $perPage,
|
||||||
|
'sort' => $sort,
|
||||||
|
]);
|
||||||
|
|
||||||
return ArtworkListResource::collection($paginator);
|
return ArtworkListResource::collection($paginator);
|
||||||
}
|
}
|
||||||
@@ -36,14 +41,20 @@ class BrowseController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function byContentType(Request $request, string $contentTypeSlug)
|
public function byContentType(Request $request, string $contentTypeSlug)
|
||||||
{
|
{
|
||||||
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
|
$perPage = $this->resolvePerPage($request);
|
||||||
|
$sort = (string) $request->get('sort', 'latest');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage);
|
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage, $sort);
|
||||||
} catch (ModelNotFoundException $e) {
|
} catch (ModelNotFoundException $e) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$paginator->appends([
|
||||||
|
'limit' => $perPage,
|
||||||
|
'sort' => $sort,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($paginator->count() === 0) {
|
if ($paginator->count() === 0) {
|
||||||
return response()->json(['message' => 'Gone'], 410);
|
return response()->json(['message' => 'Gone'], 410);
|
||||||
}
|
}
|
||||||
@@ -57,22 +68,38 @@ class BrowseController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath)
|
public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath)
|
||||||
{
|
{
|
||||||
$perPage = min(max((int) $request->get('per_page', 24), 1), 100);
|
$perPage = $this->resolvePerPage($request);
|
||||||
|
$sort = (string) $request->get('sort', 'latest');
|
||||||
|
|
||||||
$slugs = array_merge([
|
$slugs = array_merge([
|
||||||
strtolower($contentTypeSlug),
|
strtolower($contentTypeSlug),
|
||||||
], array_values(array_filter(explode('/', trim($categoryPath, '/')))));
|
], array_values(array_filter(explode('/', trim($categoryPath, '/')))));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage);
|
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage, $sort);
|
||||||
} catch (ModelNotFoundException $e) {
|
} catch (ModelNotFoundException $e) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$paginator->appends([
|
||||||
|
'limit' => $perPage,
|
||||||
|
'sort' => $sort,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($paginator->count() === 0) {
|
if ($paginator->count() === 0) {
|
||||||
return response()->json(['message' => 'Gone'], 410);
|
return response()->json(['message' => 'Gone'], 410);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ArtworkListResource::collection($paginator);
|
return ArtworkListResource::collection($paginator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolvePerPage(Request $request): int
|
||||||
|
{
|
||||||
|
$limit = (int) $request->query('limit', 0);
|
||||||
|
$perPage = (int) $request->query('per_page', 0);
|
||||||
|
|
||||||
|
$value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 24);
|
||||||
|
|
||||||
|
return min(max($value, 1), 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
app/Http/Controllers/Api/CommunityActivityController.php
Normal file
49
app/Http/Controllers/Api/CommunityActivityController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\CommunityActivityService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class CommunityActivityController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CommunityActivityService $activityService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$filter = $this->resolveFilter($request);
|
||||||
|
|
||||||
|
if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) {
|
||||||
|
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$feed = $this->activityService->getFeed(
|
||||||
|
viewer: $request->user(),
|
||||||
|
filter: $filter,
|
||||||
|
page: (int) $request->query('page', 1),
|
||||||
|
perPage: (int) $request->query('per_page', CommunityActivityService::DEFAULT_PER_PAGE),
|
||||||
|
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFilter(Request $request): string
|
||||||
|
{
|
||||||
|
if ($request->filled('type') && ! $request->filled('filter')) {
|
||||||
|
return (string) $request->query('type', 'all');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||||
|
return 'following';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $request->query('filter', 'all');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Controllers/Api/DiscoveryEventController.php
Normal file
49
app/Http/Controllers/Api/DiscoveryEventController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Jobs\IngestUserDiscoveryEventJob;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class DiscoveryEventController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'event_id' => ['nullable', 'uuid'],
|
||||||
|
'event_type' => ['required', 'string', 'in:view,click,favorite,download'],
|
||||||
|
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||||
|
'occurred_at' => ['nullable', 'date'],
|
||||||
|
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||||
|
'meta' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$eventId = (string) ($payload['event_id'] ?? (string) Str::uuid());
|
||||||
|
$algoVersion = (string) ($payload['algo_version'] ?? config('discovery.algo_version', 'clip-cosine-v1'));
|
||||||
|
$occurredAt = isset($payload['occurred_at'])
|
||||||
|
? (string) $payload['occurred_at']
|
||||||
|
: now()->toIso8601String();
|
||||||
|
|
||||||
|
IngestUserDiscoveryEventJob::dispatch(
|
||||||
|
eventId: $eventId,
|
||||||
|
userId: (int) $request->user()->id,
|
||||||
|
artworkId: (int) $payload['artwork_id'],
|
||||||
|
eventType: (string) $payload['event_type'],
|
||||||
|
algoVersion: $algoVersion,
|
||||||
|
occurredAt: $occurredAt,
|
||||||
|
meta: (array) ($payload['meta'] ?? [])
|
||||||
|
)->onQueue((string) config('discovery.queue', 'default'));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'queued' => true,
|
||||||
|
'event_id' => $eventId,
|
||||||
|
'algo_version' => $algoVersion,
|
||||||
|
], Response::HTTP_ACCEPTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Controllers/Api/FeedAnalyticsController.php
Normal file
45
app/Http/Controllers/Api/FeedAnalyticsController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class FeedAnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'event_type' => ['required', 'string', 'in:feed_impression,feed_click'],
|
||||||
|
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||||
|
'position' => ['nullable', 'integer', 'min:1', 'max:500'],
|
||||||
|
'algo_version' => ['required', 'string', 'max:64'],
|
||||||
|
'source' => ['required', 'string', 'in:personalized,cold_start,fallback'],
|
||||||
|
'dwell_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
|
||||||
|
'occurred_at' => ['nullable', 'date'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$occurredAt = isset($payload['occurred_at']) ? now()->parse((string) $payload['occurred_at']) : now();
|
||||||
|
|
||||||
|
DB::table('feed_events')->insert([
|
||||||
|
'event_date' => $occurredAt->toDateString(),
|
||||||
|
'event_type' => (string) $payload['event_type'],
|
||||||
|
'user_id' => (int) $request->user()->id,
|
||||||
|
'artwork_id' => (int) $payload['artwork_id'],
|
||||||
|
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
|
||||||
|
'algo_version' => (string) $payload['algo_version'],
|
||||||
|
'source' => (string) $payload['source'],
|
||||||
|
'dwell_seconds' => isset($payload['dwell_seconds']) ? (int) $payload['dwell_seconds'] : null,
|
||||||
|
'occurred_at' => $occurredAt,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['success' => true], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Controllers/Api/FeedController.php
Normal file
35
app/Http/Controllers/Api/FeedController.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Recommendations\PersonalizedFeedService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class FeedController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PersonalizedFeedService $feedService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||||
|
'cursor' => ['nullable', 'string', 'max:512'],
|
||||||
|
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->feedService->getFeed(
|
||||||
|
userId: (int) $request->user()->id,
|
||||||
|
limit: isset($payload['limit']) ? (int) $payload['limit'] : 24,
|
||||||
|
cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null,
|
||||||
|
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
app/Http/Controllers/Api/FollowController.php
Normal file
137
app/Http/Controllers/Api/FollowController.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\FollowService;
|
||||||
|
use App\Support\AvatarUrl;
|
||||||
|
use App\Support\UsernamePolicy;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoints for the follow system.
|
||||||
|
*
|
||||||
|
* POST /api/user/{username}/follow → follow a user
|
||||||
|
* DELETE /api/user/{username}/follow → unfollow a user
|
||||||
|
* GET /api/user/{username}/followers → paginated followers list
|
||||||
|
* GET /api/user/{username}/following → paginated following list
|
||||||
|
*/
|
||||||
|
final class FollowController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly FollowService $followService) {}
|
||||||
|
|
||||||
|
// ─── POST /api/user/{username}/follow ────────────────────────────────────
|
||||||
|
|
||||||
|
public function follow(Request $request, string $username): JsonResponse
|
||||||
|
{
|
||||||
|
$target = $this->resolveUser($username);
|
||||||
|
$actor = Auth::user();
|
||||||
|
|
||||||
|
if ($actor->id === $target->id) {
|
||||||
|
return response()->json(['error' => 'Cannot follow yourself.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->followService->follow((int) $actor->id, (int) $target->id);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'following' => true,
|
||||||
|
'followers_count' => $this->followService->followersCount((int) $target->id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/user/{username}/follow ──────────────────────────────────
|
||||||
|
|
||||||
|
public function unfollow(Request $request, string $username): JsonResponse
|
||||||
|
{
|
||||||
|
$target = $this->resolveUser($username);
|
||||||
|
$actor = Auth::user();
|
||||||
|
|
||||||
|
$this->followService->unfollow((int) $actor->id, (int) $target->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'following' => false,
|
||||||
|
'followers_count' => $this->followService->followersCount((int) $target->id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/user/{username}/followers ──────────────────────────────────
|
||||||
|
|
||||||
|
public function followers(Request $request, string $username): JsonResponse
|
||||||
|
{
|
||||||
|
$target = $this->resolveUser($username);
|
||||||
|
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||||
|
|
||||||
|
$rows = DB::table('user_followers as uf')
|
||||||
|
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||||
|
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||||
|
->where('uf.user_id', $target->id)
|
||||||
|
->whereNull('u.deleted_at')
|
||||||
|
->orderByDesc('uf.created_at')
|
||||||
|
->select([
|
||||||
|
'u.id', 'u.username', 'u.name',
|
||||||
|
'up.avatar_hash',
|
||||||
|
'uf.created_at as followed_at',
|
||||||
|
])
|
||||||
|
->paginate($perPage)
|
||||||
|
->through(fn ($row) => [
|
||||||
|
'id' => $row->id,
|
||||||
|
'username' => $row->username,
|
||||||
|
'display_name'=> $row->username ?? $row->name,
|
||||||
|
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||||
|
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||||
|
'followed_at' => $row->followed_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/user/{username}/following ──────────────────────────────────
|
||||||
|
|
||||||
|
public function following(Request $request, string $username): JsonResponse
|
||||||
|
{
|
||||||
|
$target = $this->resolveUser($username);
|
||||||
|
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||||
|
|
||||||
|
$rows = DB::table('user_followers as uf')
|
||||||
|
->join('users as u', 'u.id', '=', 'uf.user_id')
|
||||||
|
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||||
|
->where('uf.follower_id', $target->id)
|
||||||
|
->whereNull('u.deleted_at')
|
||||||
|
->orderByDesc('uf.created_at')
|
||||||
|
->select([
|
||||||
|
'u.id', 'u.username', 'u.name',
|
||||||
|
'up.avatar_hash',
|
||||||
|
'uf.created_at as followed_at',
|
||||||
|
])
|
||||||
|
->paginate($perPage)
|
||||||
|
->through(fn ($row) => [
|
||||||
|
'id' => $row->id,
|
||||||
|
'username' => $row->username,
|
||||||
|
'display_name'=> $row->username ?? $row->name,
|
||||||
|
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||||
|
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||||
|
'followed_at' => $row->followed_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function resolveUser(string $username): User
|
||||||
|
{
|
||||||
|
$normalized = UsernamePolicy::normalize($username);
|
||||||
|
|
||||||
|
return User::query()
|
||||||
|
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/Http/Controllers/Api/LatestCommentsApiController.php
Normal file
113
app/Http/Controllers/Api/LatestCommentsApiController.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Support\AvatarUrl;
|
||||||
|
use App\Services\ThumbnailPresenter;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class LatestCommentsApiController extends Controller
|
||||||
|
{
|
||||||
|
private const PER_PAGE = 20;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$type = $request->query('type', 'all');
|
||||||
|
|
||||||
|
// Validate filter type
|
||||||
|
if (! in_array($type, ['all', 'following', 'mine'], true)) {
|
||||||
|
$type = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'mine' and 'following' require auth
|
||||||
|
if (in_array($type, ['mine', 'following'], true) && ! $request->user()) {
|
||||||
|
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = ArtworkComment::with(['user', 'user.profile', 'artwork'])
|
||||||
|
->whereHas('artwork', function ($q) {
|
||||||
|
$q->public()->published()->whereNull('deleted_at');
|
||||||
|
})
|
||||||
|
->orderByDesc('artwork_comments.created_at');
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case 'mine':
|
||||||
|
$query->where('artwork_comments.user_id', $request->user()->id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'following':
|
||||||
|
$followingIds = $request->user()
|
||||||
|
->following()
|
||||||
|
->pluck('users.id');
|
||||||
|
$query->whereIn('artwork_comments.user_id', $followingIds);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 'all' — cache the first page only
|
||||||
|
if ((int) $request->query('page', 1) === 1) {
|
||||||
|
$cacheKey = 'comments.latest.all.page1';
|
||||||
|
$ttl = 120; // 2 minutes
|
||||||
|
|
||||||
|
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE));
|
||||||
|
} else {
|
||||||
|
$paginator = $query->paginate(self::PER_PAGE);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($paginator)) {
|
||||||
|
$paginator = $query->paginate(self::PER_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $paginator->getCollection()->map(function (ArtworkComment $c) {
|
||||||
|
$art = $c->artwork;
|
||||||
|
$user = $c->user;
|
||||||
|
|
||||||
|
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
|
||||||
|
$thumb = $present ? ($present['url'] ?? null) : null;
|
||||||
|
|
||||||
|
$userId = (int) ($c->user_id ?? 0);
|
||||||
|
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'comment_id' => $c->getKey(),
|
||||||
|
'comment_text' => e(strip_tags($c->content ?? '')),
|
||||||
|
'created_at' => $c->created_at?->toIso8601String(),
|
||||||
|
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||||
|
|
||||||
|
'commenter' => [
|
||||||
|
'id' => $userId,
|
||||||
|
'username' => $user?->username ?? null,
|
||||||
|
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||||
|
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||||
|
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||||
|
],
|
||||||
|
|
||||||
|
'artwork' => $art ? [
|
||||||
|
'id' => $art->id,
|
||||||
|
'title' => $art->title,
|
||||||
|
'slug' => $art->slug ?? Str::slug($art->title ?? ''),
|
||||||
|
'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')),
|
||||||
|
'thumb' => $thumb,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $items,
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
'has_more' => $paginator->hasMorePages(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Controllers/Api/LeaderboardController.php
Normal file
35
app/Http/Controllers/Api/LeaderboardController.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Leaderboard;
|
||||||
|
use App\Services\LeaderboardService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class LeaderboardController extends Controller
|
||||||
|
{
|
||||||
|
public function creators(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(
|
||||||
|
$leaderboards->getLeaderboard(Leaderboard::TYPE_CREATOR, (string) $request->query('period', 'weekly'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artworks(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(
|
||||||
|
$leaderboards->getLeaderboard(Leaderboard::TYPE_ARTWORK, (string) $request->query('period', 'weekly'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(
|
||||||
|
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user