Compare commits
48 Commits
master
...
547215cbe8
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||
207
.env.example
207
.env.example
@@ -45,6 +45,150 @@ BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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_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
|
||||
#
|
||||
# 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_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_PREFIX=
|
||||
|
||||
@@ -64,6 +208,23 @@ MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
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_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
@@ -71,3 +232,49 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,7 @@
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/files
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
@@ -24,3 +25,5 @@ Homestead.yaml
|
||||
Thumbs.db
|
||||
/oldSite/*
|
||||
oldSite
|
||||
packages
|
||||
/packages/*
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
@@ -6,11 +6,11 @@ class Banner
|
||||
{
|
||||
public static function ShowResponsiveAd()
|
||||
{
|
||||
echo '<div class="responsive_ad">';
|
||||
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 '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
|
||||
echo '</div>';
|
||||
#echo '<div class="responsive_ad">';
|
||||
#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 '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
|
||||
#echo '</div>';
|
||||
}
|
||||
|
||||
public static function ShowBanner300x250()
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
436
app/Console/Commands/AiTagArtworksCommand.php
Normal file
436
app/Console/Commands/AiTagArtworksCommand.php
Normal file
@@ -0,0 +1,436 @@
|
||||
<?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 an expert at analysing visual artwork and generating concise, descriptive tags.
|
||||
PROMPT;
|
||||
|
||||
private const USER_PROMPT = <<<'PROMPT'
|
||||
Analyse the artwork image and return a JSON array of relevant tags.
|
||||
Cover: art style, subject/theme, dominant colours, mood, technique, and medium where visible.
|
||||
|
||||
Rules:
|
||||
- Return ONLY a valid JSON array of lowercase strings — no markdown, no explanation.
|
||||
- Each tag must be 1–4 words, no punctuation except hyphens.
|
||||
- Between 6 and 12 tags total.
|
||||
|
||||
Example output:
|
||||
["digital painting","fantasy","portrait","dark tones","glowing eyes","detailed","dramatic lighting"]
|
||||
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);
|
||||
}
|
||||
}
|
||||
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, 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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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} {--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 array $usedUsernames = [];
|
||||
protected array $usedEmails = [];
|
||||
protected string $migrationLogPath;
|
||||
/** @var array<int,true> Legacy user IDs that qualify for import */
|
||||
protected array $activeUserIds = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
|
||||
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
|
||||
$this->migrationLogPath = storage_path('logs/username_migration.log');
|
||||
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
|
||||
|
||||
// Build the set of legacy user IDs that have any meaningful activity.
|
||||
// Users outside this set will be skipped (or deleted from the new DB if already imported).
|
||||
$this->activeUserIds = $this->buildActiveUserIds();
|
||||
$this->info('Active legacy users (uploads / comments / forum): ' . count($this->activeUserIds));
|
||||
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$purged = 0;
|
||||
|
||||
if (! DB::getPdo()) {
|
||||
if (! DB::connection('legacy')->getPdo()) {
|
||||
$this->error('Legacy DB connection "legacy" is not configured or reachable.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
DB::table('users')
|
||||
->chunkById($chunk, function ($rows) use (&$imported, &$skipped) {
|
||||
DB::connection('legacy')->table('users')
|
||||
->chunkById($chunk, function ($rows) use (&$imported, &$skipped, &$purged, $dryRun) {
|
||||
$ids = $rows->pluck('user_id')->all();
|
||||
$stats = DB::table('users_statistics')
|
||||
$stats = DB::connection('legacy')->table('users_statistics')
|
||||
->whereIn('user_id', $ids)
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
|
||||
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 {
|
||||
$this->importRow($row, $stats[$row->user_id] ?? null);
|
||||
$imported++;
|
||||
@@ -49,17 +85,70 @@ class ImportLegacyUsers extends Command
|
||||
}
|
||||
}, 'user_id');
|
||||
|
||||
$this->info("Imported: {$imported}, Skipped: {$skipped}");
|
||||
$this->info("Imported: {$imported}, Skipped: {$skipped}, Purged: {$purged}");
|
||||
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
|
||||
{
|
||||
$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 (sanitized only, no numeric suffixing — was unique in old DB).
|
||||
$username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
|
||||
|
||||
$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;
|
||||
|
||||
@@ -84,26 +173,40 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
||||
$now = now();
|
||||
$alreadyExists = DB::table('users')->where('id', $legacyId)->exists();
|
||||
|
||||
DB::table('users')->insert([
|
||||
'id' => $legacyId,
|
||||
// All fields synced from legacy on every run
|
||||
$sharedFields = [
|
||||
'username' => $username,
|
||||
'username_changed_at' => $now,
|
||||
'name' => $row->real_name ?: $username,
|
||||
'email' => $email,
|
||||
'password' => $passwordHash,
|
||||
'is_active' => (int) ($row->active ?? 1) === 1,
|
||||
'needs_password_reset' => true,
|
||||
'role' => 'user',
|
||||
'legacy_password_algo' => null,
|
||||
'last_visit_at' => $row->LastVisit ?: null,
|
||||
'created_at' => $row->joinDate ?: $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
];
|
||||
|
||||
DB::table('user_profiles')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'bio' => $row->about_me ?: $row->description ?: null,
|
||||
'avatar' => $row->picture ?: null,
|
||||
if ($alreadyExists) {
|
||||
// Sync all fields from legacy — password is never overwritten on re-runs
|
||||
// (unless --force-reset-all was passed, in which case the caller handles it
|
||||
// separately outside this transaction).
|
||||
DB::table('users')->where('id', $legacyId)->update($sharedFields);
|
||||
} else {
|
||||
DB::table('users')->insert(array_merge($sharedFields, [
|
||||
'id' => $legacyId,
|
||||
'password' => $passwordHash,
|
||||
'created_at' => $row->joinDate ?: $now,
|
||||
]));
|
||||
}
|
||||
|
||||
DB::table('user_profiles')->updateOrInsert(
|
||||
['user_id' => $legacyId],
|
||||
[
|
||||
'about' => $row->about_me ?: $row->description ?: null,
|
||||
'avatar_legacy' => $row->picture ?: null,
|
||||
'cover_image' => $row->cover_art ?: null,
|
||||
'country' => $row->country ?: null,
|
||||
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
||||
@@ -111,29 +214,37 @@ class ImportLegacyUsers extends Command
|
||||
'birthdate' => $row->birth ?: null,
|
||||
'gender' => $row->gender ?: 'X',
|
||||
'website' => $row->web ?: null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
]
|
||||
);
|
||||
|
||||
if (!empty($row->web)) {
|
||||
DB::table('user_social_links')->insert([
|
||||
// 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 = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
||||
if ($old !== '' && $old !== $username) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $old],
|
||||
[
|
||||
'new_username' => $username,
|
||||
'user_id' => $legacyId,
|
||||
'platform' => 'website',
|
||||
'url' => $row->web,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('user_statistics')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'uploads' => $uploads,
|
||||
'downloads' => $downloads,
|
||||
'pageviews' => $pageviews,
|
||||
'awards' => $awards,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,45 +262,7 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
protected function sanitizeUsername(string $username): string
|
||||
{
|
||||
$username = strtolower(trim($username));
|
||||
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
|
||||
return trim($username, '.-') ?: 'user';
|
||||
}
|
||||
|
||||
protected function uniqueUsername(string $base): string
|
||||
{
|
||||
$name = $base;
|
||||
$i = 1;
|
||||
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
|
||||
$name = $base . '-' . $i;
|
||||
$i++;
|
||||
}
|
||||
$this->usedUsernames[$name] = $name;
|
||||
return $name;
|
||||
}
|
||||
|
||||
protected function prepareEmail(?string $legacyEmail, string $username): string
|
||||
{
|
||||
$legacyEmail = $legacyEmail ? strtolower(trim($legacyEmail)) : null;
|
||||
$baseLocal = $this->sanitizeEmailLocal($username);
|
||||
$domain = 'users.skinbase.org';
|
||||
|
||||
$email = $legacyEmail ?: ($baseLocal . '@' . $domain);
|
||||
$email = $this->uniqueEmail($email, $baseLocal, $domain);
|
||||
return $email;
|
||||
}
|
||||
|
||||
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;
|
||||
return UsernamePolicy::sanitizeLegacy($username);
|
||||
}
|
||||
|
||||
protected function sanitizeEmailLocal(string $value): string
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
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,20 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
use App\Console\Commands\ImportLegacyUsers;
|
||||
use App\Console\Commands\ImportCategories;
|
||||
use App\Console\Commands\MigrateFeaturedWorks;
|
||||
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
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\RankComputeArtworkScoresJob;
|
||||
use App\Jobs\RankBuildListsJob;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
use App\Console\Commands\PublishScheduledArtworksCommand;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
@@ -16,9 +30,25 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected $commands = [
|
||||
ImportLegacyUsers::class,
|
||||
\App\Console\Commands\EnforceUsernamePolicy::class,
|
||||
ImportCategories::class,
|
||||
MigrateFeaturedWorks::class,
|
||||
\App\Console\Commands\AvatarsMigrate::class,
|
||||
\App\Console\Commands\AvatarsBulkUpdate::class,
|
||||
\App\Console\Commands\ResetAllUserPasswords::class,
|
||||
CleanupUploadsCommand::class,
|
||||
PublishScheduledArtworksCommand::class,
|
||||
BackfillArtworkEmbeddingsCommand::class,
|
||||
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||
AggregateFeedAnalyticsCommand::class,
|
||||
EvaluateFeedWeightsCommand::class,
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
RecalculateTrendingCommand::class,
|
||||
RecalculateRankingsCommand::class,
|
||||
MetricsSnapshotHourlyCommand::class,
|
||||
RecalculateHeatCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -26,7 +56,49 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
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');
|
||||
// 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();
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
19
app/Events/MessageSent.php
Normal file
19
app/Events/MessageSent.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MessageSent
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $conversationId,
|
||||
public int $messageId,
|
||||
public int $senderId,
|
||||
) {}
|
||||
}
|
||||
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,
|
||||
) {}
|
||||
}
|
||||
16
app/Events/TypingStarted.php
Normal file
16
app/Events/TypingStarted.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TypingStarted
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $conversationId,
|
||||
public int $userId,
|
||||
) {}
|
||||
}
|
||||
16
app/Events/TypingStopped.php
Normal file
16
app/Events/TypingStopped.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TypingStopped
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $conversationId,
|
||||
public int $userId,
|
||||
) {}
|
||||
}
|
||||
119
app/Http/Controllers/Admin/EarlyGrowthAdminController.php
Normal file
119
app/Http/Controllers/Admin/EarlyGrowthAdminController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EarlyGrowth\ActivityLayer;
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* EarlyGrowthAdminController (§14)
|
||||
*
|
||||
* Admin panel for the Early-Stage Growth System.
|
||||
* All toggles are ENV-driven; updating .env requires a deploy.
|
||||
* This panel provides a read-only status view plus a cache-flush action.
|
||||
*
|
||||
* Future v2: wire to a `settings` DB table so admins can toggle without
|
||||
* a deploy. The EarlyGrowth::enabled() contract already supports this.
|
||||
*/
|
||||
final class EarlyGrowthAdminController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdaptiveTimeWindow $timeWindow,
|
||||
private readonly ActivityLayer $activityLayer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /admin/early-growth
|
||||
* Status dashboard: shows current config, live stats, toggle instructions.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$uploadsPerDay = $this->timeWindow->getUploadsPerDay();
|
||||
|
||||
return view('admin.early-growth.index', [
|
||||
'status' => EarlyGrowth::status(),
|
||||
'mode' => EarlyGrowth::mode(),
|
||||
'uploads_per_day' => $uploadsPerDay,
|
||||
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
|
||||
'activity' => $this->activityLayer->getSignals(),
|
||||
'cache_keys' => [
|
||||
'egs.uploads_per_day',
|
||||
'egs.auto_disable_check',
|
||||
'egs.spotlight.*',
|
||||
'egs.curated.*',
|
||||
'egs.grid_filler.*',
|
||||
'egs.activity_signals',
|
||||
'homepage.fresh.*',
|
||||
'discover.trending.*',
|
||||
'discover.rising.*',
|
||||
],
|
||||
'env_toggles' => [
|
||||
['key' => 'NOVA_EARLY_GROWTH_ENABLED', 'current' => env('NOVA_EARLY_GROWTH_ENABLED', 'false')],
|
||||
['key' => 'NOVA_EARLY_GROWTH_MODE', 'current' => env('NOVA_EARLY_GROWTH_MODE', 'off')],
|
||||
['key' => 'NOVA_EGS_ADAPTIVE_WINDOW', 'current' => env('NOVA_EGS_ADAPTIVE_WINDOW', 'true')],
|
||||
['key' => 'NOVA_EGS_GRID_FILLER', 'current' => env('NOVA_EGS_GRID_FILLER', 'true')],
|
||||
['key' => 'NOVA_EGS_SPOTLIGHT', 'current' => env('NOVA_EGS_SPOTLIGHT', 'true')],
|
||||
['key' => 'NOVA_EGS_ACTIVITY_LAYER', 'current' => env('NOVA_EGS_ACTIVITY_LAYER', 'false')],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /admin/early-growth/cache
|
||||
* Flush all EGS-related cache keys so new config changes take effect immediately.
|
||||
*/
|
||||
public function flushCache(Request $request): RedirectResponse
|
||||
{
|
||||
$keys = [
|
||||
'egs.uploads_per_day',
|
||||
'egs.auto_disable_check',
|
||||
'egs.activity_signals',
|
||||
];
|
||||
|
||||
// Flush the EGS daily spotlight caches for today
|
||||
$today = now()->format('Y-m-d');
|
||||
foreach ([6, 12, 18, 24] as $n) {
|
||||
Cache::forget("egs.spotlight.{$today}.{$n}");
|
||||
Cache::forget("egs.curated.{$today}.{$n}.7");
|
||||
}
|
||||
|
||||
// Flush fresh/trending homepage sections
|
||||
foreach ([6, 8, 10, 12] as $limit) {
|
||||
foreach (['off', 'light', 'aggressive'] as $mode) {
|
||||
Cache::forget("homepage.fresh.{$limit}.egs-{$mode}");
|
||||
Cache::forget("homepage.fresh.{$limit}.std");
|
||||
}
|
||||
Cache::forget("homepage.trending.{$limit}");
|
||||
Cache::forget("homepage.rising.{$limit}");
|
||||
}
|
||||
|
||||
// Flush key keys
|
||||
foreach ($keys as $key) {
|
||||
Cache::forget($key);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.early-growth.index')
|
||||
->with('success', 'Early Growth System cache flushed. Changes will take effect on next page load.');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/early-growth/status (JSON — for monitoring/healthcheck)
|
||||
*/
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'egs' => EarlyGrowth::status(),
|
||||
'uploads_per_day' => $this->timeWindow->getUploadsPerDay(),
|
||||
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
149
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal file
149
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?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 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();
|
||||
$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,
|
||||
];
|
||||
}
|
||||
}
|
||||
220
app/Http/Controllers/Api/ArtworkCommentController.php
Normal file
220
app/Http/Controllers/Api/ArtworkCommentController.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\LegacySmileyMapper;
|
||||
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']);
|
||||
|
||||
// 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' => $c->rendered_content ?? e(strip_tags($c->content ?? '')),
|
||||
'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),
|
||||
],
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Artworks\ArtworkCreateRequest;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ArtworkController extends Controller
|
||||
{
|
||||
@@ -17,6 +20,32 @@ class ArtworkController extends Controller
|
||||
$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}
|
||||
* Returns a single public artwork resource by slug.
|
||||
|
||||
128
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal file
128
app/Http/Controllers/Api/ArtworkDownloadController.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?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,
|
||||
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
|
||||
'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 '';
|
||||
}
|
||||
}
|
||||
223
app/Http/Controllers/Api/ArtworkInteractionController.php
Normal file
223
app/Http/Controllers/Api/ArtworkInteractionController.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\UserStatsService;
|
||||
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 favorite(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$state = $request->boolean('state', true);
|
||||
|
||||
$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) {
|
||||
$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) {}
|
||||
} else {
|
||||
$svc->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
public function like(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$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);
|
||||
|
||||
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->boolean('state', true);
|
||||
|
||||
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
|
||||
): void {
|
||||
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));
|
||||
}
|
||||
} else {
|
||||
$query->delete();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$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;
|
||||
|
||||
$likes = Schema::hasTable('artwork_likes')
|
||||
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'is_favorited' => $isFavorited,
|
||||
'is_liked' => $isLiked,
|
||||
'stats' => [
|
||||
'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);
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Api/ArtworkViewController.php
Normal file
62
app/Http/Controllers/Api/ArtworkViewController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
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) {}
|
||||
|
||||
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);
|
||||
|
||||
// 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)
|
||||
{
|
||||
$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);
|
||||
}
|
||||
@@ -36,14 +41,20 @@ class BrowseController extends Controller
|
||||
*/
|
||||
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 {
|
||||
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage);
|
||||
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage, $sort);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$paginator->appends([
|
||||
'limit' => $perPage,
|
||||
'sort' => $sort,
|
||||
]);
|
||||
|
||||
if ($paginator->count() === 0) {
|
||||
return response()->json(['message' => 'Gone'], 410);
|
||||
}
|
||||
@@ -57,22 +68,38 @@ class BrowseController extends Controller
|
||||
*/
|
||||
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([
|
||||
strtolower($contentTypeSlug),
|
||||
], array_values(array_filter(explode('/', trim($categoryPath, '/')))));
|
||||
|
||||
try {
|
||||
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage);
|
||||
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage, $sort);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$paginator->appends([
|
||||
'limit' => $perPage,
|
||||
'sort' => $sort,
|
||||
]);
|
||||
|
||||
if ($paginator->count() === 0) {
|
||||
return response()->json(['message' => 'Gone'], 410);
|
||||
}
|
||||
|
||||
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/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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
223
app/Http/Controllers/Api/LinkPreviewController.php
Normal file
223
app/Http/Controllers/Api/LinkPreviewController.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\TransferException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LinkPreviewController extends Controller
|
||||
{
|
||||
private const TIMEOUT = 8; // seconds
|
||||
private const MAX_BYTES = 524_288; // 512 KB – enough to get the <head>
|
||||
private const USER_AGENT = 'Skinbase-LinkPreview/1.0 (+https://skinbase.org)';
|
||||
|
||||
/** Blocked IP ranges (SSRF protection). */
|
||||
private const BLOCKED_CIDRS = [
|
||||
'0.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'100.64.0.0/10',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'172.16.0.0/12',
|
||||
'192.0.0.0/24',
|
||||
'192.168.0.0/16',
|
||||
'198.18.0.0/15',
|
||||
'198.51.100.0/24',
|
||||
'203.0.113.0/24',
|
||||
'240.0.0.0/4',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'url' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$rawUrl = trim((string) $request->input('url'));
|
||||
|
||||
// Must be http(s)
|
||||
if (! preg_match('#^https?://#i', $rawUrl)) {
|
||||
return response()->json(['error' => 'Invalid URL scheme.'], 422);
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$host = $parsed['host'] ?? '';
|
||||
|
||||
if (empty($host)) {
|
||||
return response()->json(['error' => 'Invalid URL.'], 422);
|
||||
}
|
||||
|
||||
// Resolve hostname and block private/loopback IPs (SSRF protection)
|
||||
$resolved = gethostbyname($host);
|
||||
if ($this->isBlockedIp($resolved)) {
|
||||
return response()->json(['error' => 'URL not allowed.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new Client([
|
||||
'timeout' => self::TIMEOUT,
|
||||
'connect_timeout' => 4,
|
||||
'allow_redirects' => ['max' => 5, 'strict' => false],
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
'Accept' => 'text/html,application/xhtml+xml',
|
||||
],
|
||||
'verify' => true,
|
||||
]);
|
||||
|
||||
$response = $client->get($rawUrl);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if ($status < 200 || $status >= 400) {
|
||||
return response()->json(['error' => 'Could not fetch URL.'], 422);
|
||||
}
|
||||
|
||||
// Read up to MAX_BYTES – we only need the HTML <head>
|
||||
$body = '';
|
||||
$stream = $response->getBody();
|
||||
while (! $stream->eof() && strlen($body) < self::MAX_BYTES) {
|
||||
$body .= $stream->read(4096);
|
||||
}
|
||||
$stream->close();
|
||||
|
||||
} catch (TransferException $e) {
|
||||
return response()->json(['error' => 'Could not reach URL.'], 422);
|
||||
}
|
||||
|
||||
$preview = $this->extractMeta($body, $rawUrl);
|
||||
|
||||
return response()->json($preview);
|
||||
}
|
||||
|
||||
/** Extract OG / Twitter / fallback meta tags. */
|
||||
private function extractMeta(string $html, string $originalUrl): array
|
||||
{
|
||||
// Limit to roughly the <head> block for speed
|
||||
$head = substr($html, 0, 50_000);
|
||||
|
||||
$og = [];
|
||||
|
||||
// OG / Twitter meta tags
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m1,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m2,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
|
||||
$allMeta = array_merge(
|
||||
array_map(fn ($r) => ['key' => strtolower($r[1]), 'value' => $r[2]], $m1),
|
||||
array_map(fn ($r) => ['key' => strtolower($r[2]), 'value' => $r[1]], $m2),
|
||||
);
|
||||
|
||||
$map = [];
|
||||
foreach ($allMeta as $entry) {
|
||||
$map[$entry['key']] ??= $entry['value'];
|
||||
}
|
||||
|
||||
// Canonical URL
|
||||
$canonical = $originalUrl;
|
||||
if (preg_match('/<link[^>]+rel\s*=\s*["\']canonical["\'][^>]+href\s*=\s*["\']([^"\']+)["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
} elseif (preg_match('/<link[^>]+href\s*=\s*["\']([^"\']+)["\'][^>]+rel\s*=\s*["\']canonical["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
}
|
||||
|
||||
// Title
|
||||
$title = $map['og:title']
|
||||
?? $map['twitter:title']
|
||||
?? null;
|
||||
if (! $title && preg_match('/<title[^>]*>([^<]+)<\/title>/i', $head, $mt)) {
|
||||
$title = trim(html_entity_decode($mt[1]));
|
||||
}
|
||||
|
||||
// Description
|
||||
$description = $map['og:description']
|
||||
?? $map['twitter:description']
|
||||
?? $map['description']
|
||||
?? null;
|
||||
|
||||
// Image
|
||||
$image = $map['og:image']
|
||||
?? $map['twitter:image']
|
||||
?? $map['twitter:image:src']
|
||||
?? null;
|
||||
|
||||
// Resolve relative image URL
|
||||
if ($image && ! preg_match('#^https?://#i', $image)) {
|
||||
$parsed = parse_url($originalUrl);
|
||||
$base = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
|
||||
$image = $base . '/' . ltrim($image, '/');
|
||||
}
|
||||
|
||||
// Site name
|
||||
$siteName = $map['og:site_name'] ?? parse_url($originalUrl, PHP_URL_HOST) ?? null;
|
||||
|
||||
return [
|
||||
'url' => $canonical,
|
||||
'title' => $title ? html_entity_decode($title) : null,
|
||||
'description' => $description ? html_entity_decode($description) : null,
|
||||
'image' => $image,
|
||||
'site_name' => $siteName,
|
||||
];
|
||||
}
|
||||
|
||||
private function isBlockedIp(string $ip): bool
|
||||
{
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return true; // could not resolve
|
||||
}
|
||||
foreach (self::BLOCKED_CIDRS as $cidr) {
|
||||
if ($this->ipInCidr($ip, $cidr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ipInCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $cidr) + [1 => 32];
|
||||
|
||||
// IPv6
|
||||
if (str_contains($cidr, ':')) {
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
return false;
|
||||
}
|
||||
$ipBin = inet_pton($ip);
|
||||
$subnetBin = inet_pton($subnet);
|
||||
if ($ipBin === false || $subnetBin === false) {
|
||||
return false;
|
||||
}
|
||||
$bits = (int) $bits;
|
||||
$mask = str_repeat("\xff", (int) ($bits / 8));
|
||||
$remain = $bits % 8;
|
||||
if ($remain) {
|
||||
$mask .= chr(0xff << (8 - $remain));
|
||||
}
|
||||
$mask = str_pad($mask, strlen($subnetBin), "\x00");
|
||||
return ($ipBin & $mask) === ($subnetBin & $mask);
|
||||
}
|
||||
|
||||
// IPv4
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$maskLong = $bits == 32 ? -1 : ~((1 << (32 - (int) $bits)) - 1);
|
||||
return ($ipLong & $maskLong) === ($subnetLong & $maskLong);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Api/Messaging/AttachmentController.php
Normal file
41
app/Http/Controllers/Api/Messaging/AttachmentController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\MessageAttachment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
{
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$attachment = MessageAttachment::query()
|
||||
->with('message:id,conversation_id')
|
||||
->findOrFail($id);
|
||||
|
||||
$conversationId = (int) ($attachment->message?->conversation_id ?? 0);
|
||||
abort_if($conversationId <= 0, 404, 'Attachment not available.');
|
||||
|
||||
$authorized = \App\Models\ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists();
|
||||
|
||||
abort_unless($authorized, 403, 'You are not allowed to access this attachment.');
|
||||
|
||||
$diskName = (string) config('messaging.attachments.disk', 'local');
|
||||
$disk = Storage::disk($diskName);
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $attachment): void {
|
||||
echo $disk->get($attachment->storage_path);
|
||||
}, 200, [
|
||||
'Content-Type' => $attachment->mime,
|
||||
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
|
||||
'Content-Length' => (string) $attachment->size_bytes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
466
app/Http/Controllers/Api/Messaging/ConversationController.php
Normal file
466
app/Http/Controllers/Api/Messaging/ConversationController.php
Normal file
@@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use App\Services\Messaging\MessageNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ConversationController extends Controller
|
||||
{
|
||||
// ── GET /api/messages/conversations ─────────────────────────────────────
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->integer('page', 1));
|
||||
$cacheVersion = (int) Cache::get($this->cacheVersionKey($user->id), 1);
|
||||
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
|
||||
|
||||
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
|
||||
return Conversation::query()
|
||||
->select('conversations.*')
|
||||
->join('conversation_participants as cp_me', function ($join) use ($user) {
|
||||
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
||||
->where('cp_me.user_id', '=', $user->id)
|
||||
->whereNull('cp_me.left_at');
|
||||
})
|
||||
->addSelect([
|
||||
'unread_count' => Message::query()
|
||||
->selectRaw('count(*)')
|
||||
->whereColumn('messages.conversation_id', 'conversations.id')
|
||||
->where('messages.sender_id', '!=', $user->id)
|
||||
->whereNull('messages.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('cp_me.last_read_at')
|
||||
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
|
||||
}),
|
||||
])
|
||||
->with([
|
||||
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
|
||||
'latestMessage.sender:id,username',
|
||||
])
|
||||
->orderByDesc('cp_me.is_pinned')
|
||||
->orderByDesc('cp_me.pinned_at')
|
||||
->orderByDesc('last_message_at')
|
||||
->orderByDesc('conversations.id')
|
||||
->paginate(20, ['conversations.*'], 'page', $page);
|
||||
});
|
||||
|
||||
$conversations->through(function ($conv) use ($user) {
|
||||
$conv->my_participant = $conv->allParticipants
|
||||
->firstWhere('user_id', $user->id);
|
||||
return $conv;
|
||||
});
|
||||
|
||||
return response()->json($conversations);
|
||||
}
|
||||
|
||||
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
|
||||
$conv->load([
|
||||
'allParticipants.user:id,username',
|
||||
'creator:id,username',
|
||||
]);
|
||||
|
||||
return response()->json($conv);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/conversation ─────────────────────────────────────
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$data = $request->validate([
|
||||
'type' => 'required|in:direct,group',
|
||||
'recipient_id' => 'required_if:type,direct|integer|exists:users,id',
|
||||
'participant_ids' => 'required_if:type,group|array|min:2',
|
||||
'participant_ids.*'=> 'integer|exists:users,id',
|
||||
'title' => 'required_if:type,group|nullable|string|max:120',
|
||||
'body' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
if ($data['type'] === 'direct') {
|
||||
return $this->createDirect($request, $user, $data);
|
||||
}
|
||||
|
||||
return $this->createGroup($request, $user, $data);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/read ────────────────────────────
|
||||
|
||||
public function markRead(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['last_read_at' => now()]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/archive ─────────────────────────
|
||||
|
||||
public function archive(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_archived' => ! $participant->is_archived]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['is_archived' => $participant->is_archived]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/mute ────────────────────────────
|
||||
|
||||
public function mute(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_muted' => ! $participant->is_muted]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['is_muted' => $participant->is_muted]);
|
||||
}
|
||||
|
||||
public function pin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['is_pinned' => true]);
|
||||
}
|
||||
|
||||
public function unpin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
|
||||
return response()->json(['is_pinned' => false]);
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/leave ─────────────────────────
|
||||
|
||||
public function leave(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
if ($conv->isGroup()) {
|
||||
// Last admin protection
|
||||
$adminCount = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->count();
|
||||
|
||||
if ($adminCount === 1 && $participant->role === 'admin') {
|
||||
$otherMember = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', '!=', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if ($otherMember) {
|
||||
$otherMember->update(['role' => 'admin']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$participant->update(['left_at' => now()]);
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/add-user ────────────────────────
|
||||
|
||||
public function addUser(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
$this->requireAdmin($request, $id);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
$data = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
]);
|
||||
|
||||
$existing = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($existing->left_at) {
|
||||
$existing->update(['left_at' => null, 'joined_at' => now()]);
|
||||
}
|
||||
} else {
|
||||
ConversationParticipant::create([
|
||||
'conversation_id' => $id,
|
||||
'user_id' => $data['user_id'],
|
||||
'role' => 'member',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$participantUserIds[] = (int) $data['user_id'];
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/remove-user ───────────────────
|
||||
|
||||
public function removeUser(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->requireAdmin($request, $id);
|
||||
|
||||
$data = $request->validate([
|
||||
'user_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
// Cannot remove the conversation creator
|
||||
$conv = Conversation::findOrFail($id);
|
||||
abort_if($conv->created_by === (int) $data['user_id'], 403, 'Cannot remove the conversation creator.');
|
||||
|
||||
$targetParticipant = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if ($targetParticipant && $targetParticipant->role === 'admin') {
|
||||
$adminCount = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->count();
|
||||
|
||||
abort_if($adminCount <= 1, 422, 'Cannot remove the last admin from this conversation.');
|
||||
}
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
->whereNull('left_at')
|
||||
->update(['left_at' => now()]);
|
||||
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/rename ──────────────────────────
|
||||
|
||||
public function rename(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conv = $this->findAuthorized($request, $id);
|
||||
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
|
||||
$this->requireAdmin($request, $id);
|
||||
|
||||
$data = $request->validate(['title' => 'required|string|max:120']);
|
||||
$conv->update(['title' => $data['title']]);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['title' => $conv->title]);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function createDirect(Request $request, User $user, array $data): JsonResponse
|
||||
{
|
||||
$recipient = User::findOrFail($data['recipient_id']);
|
||||
|
||||
abort_if($recipient->id === $user->id, 422, 'You cannot message yourself.');
|
||||
|
||||
if (! $recipient->allowsMessagesFrom($user)) {
|
||||
abort(403, 'This user does not accept messages from you.');
|
||||
}
|
||||
|
||||
$this->assertNotBlockedBetween($user, $recipient);
|
||||
|
||||
// Reuse existing conversation if one exists
|
||||
$conv = Conversation::findDirect($user->id, $recipient->id);
|
||||
|
||||
if (! $conv) {
|
||||
$conv = DB::transaction(function () use ($user, $recipient) {
|
||||
$conv = Conversation::create([
|
||||
'type' => 'direct',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
ConversationParticipant::insert([
|
||||
['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()],
|
||||
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()],
|
||||
]);
|
||||
|
||||
return $conv;
|
||||
});
|
||||
}
|
||||
|
||||
// Insert first / next message
|
||||
$message = $conv->messages()->create([
|
||||
'sender_id' => $user->id,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$conv->update(['last_message_at' => $message->created_at]);
|
||||
app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user);
|
||||
$this->touchConversationCachesForUsers([$user->id, $recipient->id]);
|
||||
|
||||
return response()->json($conv->load('allParticipants.user:id,username'), 201);
|
||||
}
|
||||
|
||||
private function createGroup(Request $request, User $user, array $data): JsonResponse
|
||||
{
|
||||
$participantIds = array_unique(array_merge([$user->id], $data['participant_ids']));
|
||||
|
||||
$conv = DB::transaction(function () use ($user, $data, $participantIds) {
|
||||
$conv = Conversation::create([
|
||||
'type' => 'group',
|
||||
'title' => $data['title'],
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
$rows = array_map(fn ($uid) => [
|
||||
'conversation_id' => $conv->id,
|
||||
'user_id' => $uid,
|
||||
'role' => $uid === $user->id ? 'admin' : 'member',
|
||||
'joined_at' => now(),
|
||||
], $participantIds);
|
||||
|
||||
ConversationParticipant::insert($rows);
|
||||
|
||||
$message = $conv->messages()->create([
|
||||
'sender_id' => $user->id,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$conv->update(['last_message_at' => $message->created_at]);
|
||||
|
||||
return [$conv, $message];
|
||||
});
|
||||
|
||||
[$conversation, $message] = $conv;
|
||||
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user);
|
||||
$this->touchConversationCachesForUsers($participantIds);
|
||||
|
||||
return response()->json($conversation->load('allParticipants.user:id,username'), 201);
|
||||
}
|
||||
|
||||
private function findAuthorized(Request $request, int $id): Conversation
|
||||
{
|
||||
$conv = Conversation::findOrFail($id);
|
||||
$this->assertParticipant($request, $id);
|
||||
return $conv;
|
||||
}
|
||||
|
||||
private function participantRecord(Request $request, int $conversationId): ConversationParticipant
|
||||
{
|
||||
return ConversationParticipant::where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function assertParticipant(Request $request, int $id): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function requireAdmin(Request $request, int $id): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'Only admins can perform this action.'
|
||||
);
|
||||
}
|
||||
|
||||
private function touchConversationCachesForUsers(array $userIds): void
|
||||
{
|
||||
foreach (array_unique($userIds) as $userId) {
|
||||
if (! $userId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$versionKey = $this->cacheVersionKey((int) $userId);
|
||||
Cache::add($versionKey, 1, now()->addDay());
|
||||
Cache::increment($versionKey);
|
||||
}
|
||||
}
|
||||
|
||||
private function cacheVersionKey(int $userId): string
|
||||
{
|
||||
return "messages:conversations:version:{$userId}";
|
||||
}
|
||||
|
||||
private function conversationListCacheKey(int $userId, int $page, int $version): string
|
||||
{
|
||||
return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}";
|
||||
}
|
||||
|
||||
private function assertNotBlockedBetween(User $sender, User $recipient): void
|
||||
{
|
||||
if (! Schema::hasTable('user_blocks')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
|
||||
if (Schema::hasColumns('user_blocks', ['user_id', 'blocked_user_id'])) {
|
||||
$blocked = DB::table('user_blocks')
|
||||
->where(function ($q) use ($sender, $recipient) {
|
||||
$q->where('user_id', $sender->id)->where('blocked_user_id', $recipient->id);
|
||||
})
|
||||
->orWhere(function ($q) use ($sender, $recipient) {
|
||||
$q->where('user_id', $recipient->id)->where('blocked_user_id', $sender->id);
|
||||
})
|
||||
->exists();
|
||||
} elseif (Schema::hasColumns('user_blocks', ['blocker_id', 'blocked_id'])) {
|
||||
$blocked = DB::table('user_blocks')
|
||||
->where(function ($q) use ($sender, $recipient) {
|
||||
$q->where('blocker_id', $sender->id)->where('blocked_id', $recipient->id);
|
||||
})
|
||||
->orWhere(function ($q) use ($sender, $recipient) {
|
||||
$q->where('blocker_id', $recipient->id)->where('blocked_id', $sender->id);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
abort_if($blocked, 403, 'Messaging is not available between these users.');
|
||||
}
|
||||
}
|
||||
351
app/Http/Controllers/Api/Messaging/MessageController.php
Normal file
351
app/Http/Controllers/Api/Messaging/MessageController.php
Normal file
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\MessageSent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\MessageAttachment;
|
||||
use App\Models\MessageReaction;
|
||||
use App\Services\Messaging\MessageSearchIndexer;
|
||||
use App\Services\Messaging\MessageNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
private const PAGE_SIZE = 30;
|
||||
|
||||
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
$cursor = $request->integer('cursor');
|
||||
|
||||
$query = Message::withTrashed()
|
||||
->where('conversation_id', $conversationId)
|
||||
->with(['sender:id,username', 'reactions', 'attachments'])
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($cursor) {
|
||||
$query->where('id', '<', $cursor);
|
||||
}
|
||||
|
||||
$chunk = $query->limit(self::PAGE_SIZE + 1)->get();
|
||||
$hasMore = $chunk->count() > self::PAGE_SIZE;
|
||||
$messages = $chunk->take(self::PAGE_SIZE)->reverse()->values();
|
||||
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
|
||||
|
||||
return response()->json([
|
||||
'data' => $messages,
|
||||
'next_cursor' => $nextCursor,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
|
||||
|
||||
public function store(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => 'nullable|string|max:5000',
|
||||
'attachments' => 'sometimes|array|max:5',
|
||||
'attachments.*' => 'file|max:25600',
|
||||
]);
|
||||
|
||||
$body = trim((string) ($data['body'] ?? ''));
|
||||
$files = $request->file('attachments', []);
|
||||
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conversationId,
|
||||
'sender_id' => $request->user()->id,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file instanceof UploadedFile) {
|
||||
$this->storeAttachment($file, $message, (int) $request->user()->id);
|
||||
}
|
||||
}
|
||||
|
||||
Conversation::where('id', $conversationId)
|
||||
->update(['last_message_at' => $message->created_at]);
|
||||
|
||||
$conversation = Conversation::findOrFail($conversationId);
|
||||
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user());
|
||||
app(MessageSearchIndexer::class)->indexMessage($message);
|
||||
event(new MessageSent($conversationId, $message->id, $request->user()->id));
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $conversationId)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
$message->load(['sender:id,username', 'attachments']);
|
||||
|
||||
return response()->json($message, 201);
|
||||
}
|
||||
|
||||
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
|
||||
|
||||
public function react(Request $request, int $conversationId, int $messageId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
$existing = MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
} else {
|
||||
MessageReaction::create([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/{conversation_id}/react ─────────────────────────
|
||||
|
||||
public function unreact(Request $request, int $conversationId, int $messageId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->delete();
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
public function reactByMessage(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::query()->findOrFail($messageId);
|
||||
$this->assertParticipant($request, (int) $message->conversation_id);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
$existing = MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
} else {
|
||||
MessageReaction::create([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
public function unreactByMessage(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::query()->findOrFail($messageId);
|
||||
$this->assertParticipant($request, (int) $message->conversation_id);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
MessageReaction::where([
|
||||
'message_id' => $messageId,
|
||||
'user_id' => $request->user()->id,
|
||||
'reaction' => $data['reaction'],
|
||||
])->delete();
|
||||
|
||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
// ── PATCH /api/messages/message/{messageId} ───────────────────────────────
|
||||
|
||||
public function update(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::findOrFail($messageId);
|
||||
|
||||
abort_unless(
|
||||
$message->sender_id === $request->user()->id,
|
||||
403,
|
||||
'You may only edit your own messages.'
|
||||
);
|
||||
|
||||
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
|
||||
|
||||
$data = $request->validate(['body' => 'required|string|max:5000']);
|
||||
|
||||
$message->update([
|
||||
'body' => $data['body'],
|
||||
'edited_at' => now(),
|
||||
]);
|
||||
app(MessageSearchIndexer::class)->updateMessage($message);
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json($message->fresh());
|
||||
}
|
||||
|
||||
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $messageId): JsonResponse
|
||||
{
|
||||
$message = Message::findOrFail($messageId);
|
||||
|
||||
abort_unless(
|
||||
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
|
||||
403,
|
||||
'You may only delete your own messages.'
|
||||
);
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
app(MessageSearchIndexer::class)->deleteMessage($message);
|
||||
$message->delete();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function assertParticipant(Request $request, int $conversationId): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function touchConversationCachesForUsers(array $userIds): void
|
||||
{
|
||||
foreach (array_unique($userIds) as $userId) {
|
||||
if (! $userId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$versionKey = "messages:conversations:version:{$userId}";
|
||||
Cache::add($versionKey, 1, now()->addDay());
|
||||
Cache::increment($versionKey);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertAllowedReaction(string $reaction): void
|
||||
{
|
||||
$allowed = (array) config('messaging.reactions.allowed', []);
|
||||
abort_unless(in_array($reaction, $allowed, true), 422, 'Reaction is not allowed.');
|
||||
}
|
||||
|
||||
private function reactionSummary(int $messageId, int $userId): array
|
||||
{
|
||||
$rows = MessageReaction::query()
|
||||
->selectRaw('reaction, count(*) as aggregate_count')
|
||||
->where('message_id', $messageId)
|
||||
->groupBy('reaction')
|
||||
->get();
|
||||
|
||||
$summary = [];
|
||||
foreach ($rows as $row) {
|
||||
$summary[(string) $row->reaction] = (int) $row->aggregate_count;
|
||||
}
|
||||
|
||||
$mine = MessageReaction::query()
|
||||
->where('message_id', $messageId)
|
||||
->where('user_id', $userId)
|
||||
->pluck('reaction')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$summary['me'] = $mine;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void
|
||||
{
|
||||
$mime = (string) $file->getMimeType();
|
||||
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname());
|
||||
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
|
||||
|
||||
$allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []);
|
||||
$allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []);
|
||||
|
||||
$type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file';
|
||||
$allowed = $type === 'image' ? $allowedImage : $allowedFile;
|
||||
|
||||
abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.');
|
||||
|
||||
$maxBytes = $type === 'image'
|
||||
? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024)
|
||||
: ((int) config('messaging.attachments.max_file_kb', 25600) * 1024);
|
||||
|
||||
abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.');
|
||||
|
||||
$year = now()->format('Y');
|
||||
$month = now()->format('m');
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin');
|
||||
$path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}";
|
||||
|
||||
$diskName = (string) config('messaging.attachments.disk', 'local');
|
||||
Storage::disk($diskName)->put($path, file_get_contents($file->getPathname()));
|
||||
|
||||
$width = null;
|
||||
$height = null;
|
||||
if ($type === 'image') {
|
||||
$dimensions = @getimagesize($file->getPathname());
|
||||
$width = isset($dimensions[0]) ? (int) $dimensions[0] : null;
|
||||
$height = isset($dimensions[1]) ? (int) $dimensions[1] : null;
|
||||
}
|
||||
|
||||
MessageAttachment::query()->create([
|
||||
'message_id' => $message->id,
|
||||
'user_id' => $userId,
|
||||
'type' => $type,
|
||||
'mime' => $detectedMime,
|
||||
'size_bytes' => (int) $file->getSize(),
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'sha256' => hash_file('sha256', $file->getPathname()),
|
||||
'original_name' => substr((string) $file->getClientOriginalName(), 0, 255),
|
||||
'storage_path' => $path,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
139
app/Http/Controllers/Api/Messaging/MessageSearchController.php
Normal file
139
app/Http/Controllers/Api/Messaging/MessageSearchController.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Services\Messaging\MessageSearchIndexer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Meilisearch\Client;
|
||||
|
||||
class MessageSearchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageSearchIndexer $indexer,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$data = $request->validate([
|
||||
'q' => 'required|string|min:1|max:200',
|
||||
'conversation_id' => 'nullable|integer|exists:conversations,id',
|
||||
'cursor' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$allowedConversationIds = ConversationParticipant::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->pluck('conversation_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$conversationId = isset($data['conversation_id']) ? (int) $data['conversation_id'] : null;
|
||||
if ($conversationId !== null && ! in_array($conversationId, $allowedConversationIds, true)) {
|
||||
abort(403, 'You are not a participant of this conversation.');
|
||||
}
|
||||
|
||||
if (empty($allowedConversationIds)) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null]);
|
||||
}
|
||||
|
||||
$limit = max(1, (int) config('messaging.search.page_size', 20));
|
||||
$offset = max(0, (int) ($data['cursor'] ?? 0));
|
||||
|
||||
$hits = collect();
|
||||
$estimated = 0;
|
||||
|
||||
try {
|
||||
$client = new Client(
|
||||
config('scout.meilisearch.host'),
|
||||
config('scout.meilisearch.key')
|
||||
);
|
||||
|
||||
$prefix = (string) config('scout.prefix', '');
|
||||
$indexName = $prefix . (string) config('messaging.search.index', 'messages');
|
||||
|
||||
$conversationFilter = $conversationId !== null
|
||||
? "conversation_id = {$conversationId}"
|
||||
: 'conversation_id IN [' . implode(',', $allowedConversationIds) . ']';
|
||||
|
||||
$result = $client
|
||||
->index($indexName)
|
||||
->search((string) $data['q'], [
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'sort' => ['created_at:desc'],
|
||||
'filter' => $conversationFilter,
|
||||
]);
|
||||
|
||||
$hits = collect($result->getHits() ?? []);
|
||||
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count());
|
||||
} catch (\Throwable) {
|
||||
$query = Message::query()
|
||||
->select('id')
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('conversation_id', $allowedConversationIds)
|
||||
->when($conversationId !== null, fn ($q) => $q->where('conversation_id', $conversationId))
|
||||
->where('body', 'like', '%' . (string) $data['q'] . '%')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
$estimated = (clone $query)->count();
|
||||
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
|
||||
}
|
||||
$messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
$messages = Message::query()
|
||||
->whereIn('id', $messageIds)
|
||||
->whereIn('conversation_id', $allowedConversationIds)
|
||||
->whereNull('deleted_at')
|
||||
->with(['sender:id,username', 'attachments'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$ordered = $hits
|
||||
->map(function (array $hit) use ($messages) {
|
||||
$message = $messages->get((int) ($hit['id'] ?? 0));
|
||||
if (! $message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $message->id,
|
||||
'conversation_id' => $message->conversation_id,
|
||||
'sender_id' => $message->sender_id,
|
||||
'sender' => $message->sender,
|
||||
'body' => $message->body,
|
||||
'created_at' => optional($message->created_at)?->toISOString(),
|
||||
'has_attachments' => $message->attachments->isNotEmpty(),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$nextCursor = ($offset + $limit) < $estimated ? ($offset + $limit) : null;
|
||||
|
||||
return response()->json([
|
||||
'data' => $ordered,
|
||||
'next_cursor' => $nextCursor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rebuild(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');
|
||||
|
||||
$conversationId = $request->integer('conversation_id');
|
||||
if ($conversationId > 0) {
|
||||
$this->indexer->rebuildConversation($conversationId);
|
||||
return response()->json(['queued' => true, 'scope' => 'conversation']);
|
||||
}
|
||||
|
||||
$this->indexer->rebuildAll();
|
||||
|
||||
return response()->json(['queued' => true, 'scope' => 'all']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Manages per-user messaging privacy preference.
|
||||
*
|
||||
* GET /api/messages/settings → return current setting
|
||||
* PATCH /api/messages/settings → update setting
|
||||
*/
|
||||
class MessagingSettingsController extends Controller
|
||||
{
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
|
||||
'realtime_enabled' => (bool) config('messaging.realtime', false),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'allow_messages_from' => 'required|in:everyone,followers,mutual_followers,nobody',
|
||||
]);
|
||||
|
||||
$request->user()->update($data);
|
||||
|
||||
return response()->json([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from,
|
||||
'realtime_enabled' => (bool) config('messaging.realtime', false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/Api/Messaging/TypingController.php
Normal file
96
app/Http/Controllers/Api/Messaging/TypingController.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\TypingStarted;
|
||||
use App\Events\TypingStopped;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ConversationParticipant;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class TypingController extends Controller
|
||||
{
|
||||
public function start(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
|
||||
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
|
||||
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
|
||||
|
||||
if ((bool) config('messaging.realtime', false)) {
|
||||
event(new TypingStarted($conversationId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function stop(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
$this->store()->forget($this->key($conversationId, (int) $request->user()->id));
|
||||
|
||||
if ((bool) config('messaging.realtime', false)) {
|
||||
event(new TypingStopped($conversationId, (int) $request->user()->id));
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
$userId = (int) $request->user()->id;
|
||||
|
||||
$participants = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->whereNull('left_at')
|
||||
->where('user_id', '!=', $userId)
|
||||
->with('user:id,username')
|
||||
->get();
|
||||
|
||||
$typing = $participants
|
||||
->filter(fn ($p) => $this->store()->has($this->key($conversationId, (int) $p->user_id)))
|
||||
->map(fn ($p) => [
|
||||
'user_id' => (int) $p->user_id,
|
||||
'username' => (string) ($p->user->username ?? ''),
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json(['typing' => $typing]);
|
||||
}
|
||||
|
||||
private function assertParticipant(Request $request, int $conversationId): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::query()
|
||||
->where('conversation_id', $conversationId)
|
||||
->where('user_id', $request->user()->id)
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'You are not a participant of this conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
private function key(int $conversationId, int $userId): string
|
||||
{
|
||||
return "typing:{$conversationId}:{$userId}";
|
||||
}
|
||||
|
||||
private function store(): Repository
|
||||
{
|
||||
$store = (string) config('messaging.typing.cache_store', 'redis');
|
||||
if ($store === 'redis' && ! class_exists('Redis')) {
|
||||
return Cache::store();
|
||||
}
|
||||
|
||||
try {
|
||||
return Cache::store($store);
|
||||
} catch (\Throwable) {
|
||||
return Cache::store();
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/Api/NotificationController.php
Normal file
61
app/Http/Controllers/Api/NotificationController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Services\Posts\NotificationDigestService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
/**
|
||||
* GET /api/notifications — digestd notification list
|
||||
* POST /api/notifications/read-all — mark all unread as read
|
||||
* POST /api/notifications/{id}/read — mark single as read
|
||||
*/
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function __construct(private NotificationDigestService $digest) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$notifications = $user->notifications()
|
||||
->latest()
|
||||
->limit(200) // aggregate from last 200 raw notifs
|
||||
->get();
|
||||
|
||||
$digested = $this->digest->aggregate($notifications);
|
||||
|
||||
// Simple manual pagination on the digested array
|
||||
$perPage = 20;
|
||||
$total = count($digested);
|
||||
$sliced = array_slice($digested, ($page - 1) * $perPage, $perPage);
|
||||
$unread = $user->unreadNotifications()->count();
|
||||
|
||||
return response()->json([
|
||||
'data' => array_values($sliced),
|
||||
'unread_count' => $unread,
|
||||
'meta' => [
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($total / $perPage) ?: 1,
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function readAll(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
return response()->json(['message' => 'All notifications marked as read.']);
|
||||
}
|
||||
|
||||
public function markRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notif = $request->user()->notifications()->findOrFail($id);
|
||||
$notif->markAsRead();
|
||||
return response()->json(['message' => 'Notification marked as read.']);
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Api/Posts/PostAnalyticsController.php
Normal file
44
app/Http/Controllers/Api/Posts/PostAnalyticsController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostAnalyticsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/impression — record an impression (throttled)
|
||||
* GET /api/posts/{id}/analytics — owner analytics summary
|
||||
*/
|
||||
class PostAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(private PostAnalyticsService $analytics) {}
|
||||
|
||||
public function impression(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
|
||||
// Session key: authenticated user ID or hashed IP
|
||||
$sessionKey = $request->user()
|
||||
? 'u:' . $request->user()->id
|
||||
: 'ip:' . md5($request->ip());
|
||||
|
||||
$counted = $this->analytics->trackImpression($post, $sessionKey);
|
||||
|
||||
return response()->json(['counted' => $counted]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
// Only the post owner can view analytics
|
||||
if ($request->user()?->id !== $post->user_id) {
|
||||
abort(403, 'You do not own this post.');
|
||||
}
|
||||
|
||||
return response()->json(['data' => $this->analytics->getSummary($post)]);
|
||||
}
|
||||
}
|
||||
122
app/Http/Controllers/Api/Posts/PostCommentController.php
Normal file
122
app/Http/Controllers/Api/Posts/PostCommentController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Events\Posts\PostCommented;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\CreateCommentRequest;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostCommentController extends Controller
|
||||
{
|
||||
public function __construct(private PostCountersService $counters) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $postId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$comments = PostComment::with(['user', 'user.profile'])
|
||||
->where('post_id', $post->id)
|
||||
->orderByDesc('is_highlighted') // highlighted first
|
||||
->orderBy('created_at')
|
||||
->paginate(20, ['*'], 'page', $page);
|
||||
|
||||
$formatted = $comments->getCollection()->map(fn ($c) => $this->formatComment($c));
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => [
|
||||
'total' => $comments->total(),
|
||||
'current_page' => $comments->currentPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'per_page' => $comments->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Store
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(CreateCommentRequest $request, int $postId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Rate limit: 30 comments per hour
|
||||
$key = 'comment_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 30)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're commenting too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = Post::findOrFail($postId);
|
||||
$body = ContentSanitizer::render($request->input('body'));
|
||||
|
||||
$comment = PostComment::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$this->counters->incrementComments($post);
|
||||
|
||||
// Fire event for notification
|
||||
if ($post->user_id !== $user->id) {
|
||||
event(new PostCommented($post, $comment, $user));
|
||||
}
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
|
||||
return response()->json(['comment' => $this->formatComment($comment)], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Destroy
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
Gate::authorize('delete', $comment);
|
||||
|
||||
$comment->delete();
|
||||
$this->counters->decrementComments(Post::findOrFail($postId));
|
||||
|
||||
return response()->json(['message' => 'Comment deleted.']);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Format
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function formatComment(PostComment $comment): array
|
||||
{
|
||||
return [
|
||||
'id' => $comment->id,
|
||||
'body' => $comment->body,
|
||||
'is_highlighted' => (bool) $comment->is_highlighted,
|
||||
'created_at' => $comment->created_at->toISOString(),
|
||||
'author' => [
|
||||
'id' => $comment->user->id,
|
||||
'username' => $comment->user->username,
|
||||
'name' => $comment->user->name,
|
||||
'avatar' => $comment->user->profile?->avatar_url ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{post_id}/comments/{comment_id}/highlight
|
||||
* DELETE /api/posts/{post_id}/comments/{comment_id}/highlight
|
||||
*
|
||||
* Only the post owner may highlight/un-highlight.
|
||||
* Only 1 highlighted comment per post is allowed at a time.
|
||||
*/
|
||||
class PostCommentHighlightController extends Controller
|
||||
{
|
||||
public function highlight(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
|
||||
if ($request->user()->id !== $post->user_id) {
|
||||
abort(403, 'Only the post owner can highlight comments.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($post, $comment) {
|
||||
// Remove any existing highlight on this post
|
||||
PostComment::where('post_id', $post->id)
|
||||
->where('is_highlighted', true)
|
||||
->update(['is_highlighted' => false]);
|
||||
|
||||
$comment->update(['is_highlighted' => true]);
|
||||
});
|
||||
|
||||
return response()->json(['message' => 'Comment highlighted.', 'comment_id' => $comment->id]);
|
||||
}
|
||||
|
||||
public function unhighlight(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
|
||||
if ($request->user()->id !== $post->user_id) {
|
||||
abort(403, 'Only the post owner can remove comment highlights.');
|
||||
}
|
||||
|
||||
$comment->update(['is_highlighted' => false]);
|
||||
|
||||
return response()->json(['message' => 'Highlight removed.', 'comment_id' => $comment->id]);
|
||||
}
|
||||
}
|
||||
92
app/Http/Controllers/Api/Posts/PostController.php
Normal file
92
app/Http/Controllers/Api/Posts/PostController.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\CreatePostRequest;
|
||||
use App\Http\Requests\Posts\UpdatePostRequest;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostService $postService,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Create
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(CreatePostRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Rate limit: 10 post creations per hour
|
||||
$key = 'create_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're posting too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
Gate::authorize('create', Post::class);
|
||||
|
||||
$post = $this->postService->createPost(
|
||||
user: $user,
|
||||
type: $request->input('type', Post::TYPE_TEXT),
|
||||
visibility: $request->input('visibility', Post::VISIBILITY_PUBLIC),
|
||||
body: $request->input('body'),
|
||||
targets: $request->input('targets', []),
|
||||
linkPreview: $request->input('link_preview'),
|
||||
taggedUsers: $request->input('tagged_users'), publishAt: $request->filled('publish_at') ? Carbon::parse($request->input('publish_at')) : null, );
|
||||
|
||||
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($post, $user->id),
|
||||
], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Update
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function update(UpdatePostRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$updated = $this->postService->updatePost(
|
||||
post: $post,
|
||||
body: $request->input('body'),
|
||||
visibility: $request->input('visibility'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($updated->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']), $request->user()?->id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Delete
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('delete', $post);
|
||||
|
||||
$this->postService->deletePost($post);
|
||||
|
||||
return response()->json(['message' => 'Post deleted.']);
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Api/Posts/PostFeedController.php
Normal file
60
app/Http/Controllers/Api/Posts/PostFeedController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PostFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostFeedService $feedService) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Profile feed — GET /api/posts/profile/{username}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function profile(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$profileUser = User::where('username', $username)->firstOrFail();
|
||||
$viewerId = $request->user()?->id;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$paginated = $this->feedService->getProfileFeed($profileUser, $viewerId, $page);
|
||||
|
||||
$formatted = collect($paginated['data'])
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => $paginated['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Following feed — GET /api/posts/following
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function following(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$filter = $request->query('filter', 'all');
|
||||
|
||||
$result = $this->feedService->getFollowingFeed($user, $page, $filter);
|
||||
|
||||
$viewerId = $user->id;
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewerId),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/Api/Posts/PostPinController.php
Normal file
67
app/Http/Controllers/Api/Posts/PostPinController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/pin
|
||||
* DELETE /api/posts/{id}/pin
|
||||
*/
|
||||
class PostPinController extends Controller
|
||||
{
|
||||
private const MAX_PINNED = 3;
|
||||
|
||||
public function pin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Count existing pinned posts
|
||||
$pinnedCount = Post::where('user_id', $user->id)
|
||||
->where('is_pinned', true)
|
||||
->count();
|
||||
|
||||
if ($post->is_pinned) {
|
||||
return response()->json(['message' => 'Post is already pinned.'], 409);
|
||||
}
|
||||
|
||||
if ($pinnedCount >= self::MAX_PINNED) {
|
||||
return response()->json([
|
||||
'message' => 'You can pin a maximum of ' . self::MAX_PINNED . ' posts.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$nextOrder = Post::where('user_id', $user->id)
|
||||
->where('is_pinned', true)
|
||||
->max('pinned_order') ?? 0;
|
||||
|
||||
$post->update([
|
||||
'is_pinned' => true,
|
||||
'pinned_order' => $nextOrder + 1,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Post pinned.', 'post_id' => $post->id]);
|
||||
}
|
||||
|
||||
public function unpin(int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
if (! $post->is_pinned) {
|
||||
return response()->json(['message' => 'Post is not pinned.'], 409);
|
||||
}
|
||||
|
||||
$post->update(['is_pinned' => false, 'pinned_order' => null]);
|
||||
|
||||
return response()->json(['message' => 'Post unpinned.', 'post_id' => $post->id]);
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Api/Posts/PostReactionController.php
Normal file
75
app/Http/Controllers/Api/Posts/PostReactionController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostReaction;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostReactionController extends Controller
|
||||
{
|
||||
public function __construct(private PostCountersService $counters) {}
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/reactions
|
||||
* payload: { reaction: 'like' }
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$key = 'react_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 60)) {
|
||||
return response()->json(['message' => 'Too many reactions. Please slow down.'], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = Post::findOrFail($id);
|
||||
$reaction = $request->input('reaction', 'like');
|
||||
|
||||
$existing = PostReaction::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('reaction', $reaction)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['message' => 'Already reacted.', 'reactions_count' => $post->reactions_count], 200);
|
||||
}
|
||||
|
||||
PostReaction::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
'reaction' => $reaction,
|
||||
]);
|
||||
|
||||
$this->counters->incrementReactions($post);
|
||||
$post->refresh();
|
||||
|
||||
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => true], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/posts/{id}/reactions/{reaction}
|
||||
*/
|
||||
public function destroy(Request $request, int $id, string $reaction = 'like'): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
$deleted = PostReaction::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('reaction', $reaction)
|
||||
->delete();
|
||||
|
||||
if ($deleted) {
|
||||
$this->counters->decrementReactions($post);
|
||||
$post->refresh();
|
||||
}
|
||||
|
||||
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => false]);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/Api/Posts/PostReportController.php
Normal file
49
app/Http/Controllers/Api/Posts/PostReportController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostReport;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class PostReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /api/posts/{id}/report
|
||||
* payload: { reason, message? }
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
Gate::authorize('report', $post);
|
||||
|
||||
$request->validate([
|
||||
'reason' => ['required', 'string', 'max:64'],
|
||||
'message' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
// Unique report per user+post
|
||||
$existing = PostReport::where('post_id', $post->id)
|
||||
->where('reporter_user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['message' => 'You have already reported this post.'], 409);
|
||||
}
|
||||
|
||||
PostReport::create([
|
||||
'post_id' => $post->id,
|
||||
'reporter_user_id' => $user->id,
|
||||
'reason' => $request->input('reason'),
|
||||
'message' => $request->input('message'),
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Report submitted. Thank you for helping keep Skinbase safe.'], 201);
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Api/Posts/PostSaveController.php
Normal file
69
app/Http/Controllers/Api/Posts/PostSaveController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostSave;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/save
|
||||
* DELETE /api/posts/{id}/save
|
||||
* GET /api/posts/saved
|
||||
*/
|
||||
class PostSaveController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostCountersService $counters,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
public function save(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
$user = $request->user();
|
||||
|
||||
if (PostSave::where('post_id', $post->id)->where('user_id', $user->id)->exists()) {
|
||||
return response()->json(['message' => 'Already saved.', 'saved' => true], 200);
|
||||
}
|
||||
|
||||
PostSave::create(['post_id' => $post->id, 'user_id' => $user->id]);
|
||||
$this->counters->incrementSaves($post);
|
||||
|
||||
return response()->json(['message' => 'Post saved.', 'saved' => true, 'saves_count' => $post->fresh()->saves_count]);
|
||||
}
|
||||
|
||||
public function unsave(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
$user = $request->user();
|
||||
$save = PostSave::where('post_id', $post->id)->where('user_id', $user->id)->first();
|
||||
|
||||
if (! $save) {
|
||||
return response()->json(['message' => 'Not saved.', 'saved' => false], 200);
|
||||
}
|
||||
|
||||
$save->delete();
|
||||
$this->counters->decrementSaves($post);
|
||||
|
||||
return response()->json(['message' => 'Post unsaved.', 'saved' => false, 'saves_count' => $post->fresh()->saves_count]);
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$result = $this->feedService->getSavedFeed($user, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $user->id),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/Api/Posts/PostSearchController.php
Normal file
85
app/Http/Controllers/Api/Posts/PostSearchController.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/feed/search?q=...
|
||||
*
|
||||
* Searches posts body + hashtags via Meilisearch (Laravel Scout).
|
||||
* Falls back to a simple LIKE query if Scout is unavailable.
|
||||
*/
|
||||
class PostSearchController extends Controller
|
||||
{
|
||||
public function __construct(private PostFeedService $feedService) {}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'q' => ['required', 'string', 'min:2', 'max:100'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$query = trim($request->input('q'));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
$viewerId = $request->user()?->id;
|
||||
|
||||
// Scout search (Meilisearch)
|
||||
try {
|
||||
$results = Post::search($query)
|
||||
->where('visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->paginate($perPage, 'page', $page);
|
||||
|
||||
// Load relations
|
||||
$results->load($this->feedService->publicEagerLoads());
|
||||
|
||||
$formatted = $results->getCollection()
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'query' => $query,
|
||||
'meta' => [
|
||||
'total' => $results->total(),
|
||||
'current_page' => $results->currentPage(),
|
||||
'last_page' => $results->lastPage(),
|
||||
'per_page' => $results->perPage(),
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback: basic LIKE search on body
|
||||
$paginated = Post::with($this->feedService->publicEagerLoads())
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->where('visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('body', 'like', '%' . $query . '%')
|
||||
->orWhereHas('hashtags', fn ($hq) => $hq->where('tag', 'like', '%' . mb_strtolower($query) . '%'));
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$formatted = $paginated->getCollection()
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'query' => $query,
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Api/Posts/PostShareController.php
Normal file
58
app/Http/Controllers/Api/Posts/PostShareController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Events\Posts\ArtworkShared;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\ShareArtworkRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostShareService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostShareController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostShareService $shareService,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/posts/share/artwork/{artwork_id}
|
||||
* payload: { body?, visibility }
|
||||
*/
|
||||
public function shareArtwork(ShareArtworkRequest $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($artworkId);
|
||||
|
||||
// Rate limit: 10 artwork shares per hour
|
||||
$key = 'share_artwork:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're sharing too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = $this->shareService->shareArtwork(
|
||||
user: $user,
|
||||
artwork: $artwork,
|
||||
body: $request->input('body'),
|
||||
visibility: $request->input('visibility', 'public'),
|
||||
);
|
||||
|
||||
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
|
||||
|
||||
// Notify original artwork owner (unless self-share)
|
||||
if ($artwork->user_id !== $user->id) {
|
||||
event(new ArtworkShared($post, $artwork, $user));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($post, $user->id),
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use App\Services\Posts\PostTrendingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* GET /api/feed/trending
|
||||
* GET /api/feed/hashtag/{tag}
|
||||
* GET /api/feed/hashtags/trending
|
||||
*/
|
||||
class PostTrendingFeedController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostTrendingService $trendingService,
|
||||
private PostFeedService $feedService,
|
||||
private PostHashtagService $hashtagService,
|
||||
) {}
|
||||
|
||||
public function trending(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$viewer = $request->user()?->id;
|
||||
|
||||
$result = $this->trendingService->getTrending($viewer, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewer),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
|
||||
}
|
||||
|
||||
public function hashtag(Request $request, string $tag): JsonResponse
|
||||
{
|
||||
$tag = mb_strtolower(preg_replace('/[^A-Za-z0-9_]/', '', $tag));
|
||||
if (strlen($tag) < 2 || strlen($tag) > 64) {
|
||||
return response()->json(['message' => 'Invalid hashtag.'], 422);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$viewer = $request->user()?->id;
|
||||
|
||||
$result = $this->feedService->getHashtagFeed($tag, $viewer, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewer),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'tag' => $tag,
|
||||
'data' => array_values($formatted),
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function trendingHashtags(): JsonResponse
|
||||
{
|
||||
$tags = Cache::remember('trending_hashtags', 300, function () {
|
||||
return $this->hashtagService->trending(10, 24);
|
||||
});
|
||||
|
||||
return response()->json(['hashtags' => $tags]);
|
||||
}
|
||||
}
|
||||
177
app/Http/Controllers/Api/ProfileApiController.php
Normal file
177
app/Http/Controllers/Api/ProfileApiController.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
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;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* ProfileApiController
|
||||
* JSON API endpoints for Profile page v2 tabs.
|
||||
*/
|
||||
final class ProfileApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/profile/{username}/artworks
|
||||
* Returns cursor-paginated artworks for the profile page tabs.
|
||||
* Supports: sort=latest|trending|rising|views|favs, cursor=...
|
||||
*/
|
||||
public function artworks(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$sort = $request->input('sort', 'latest');
|
||||
|
||||
$query = Artwork::with('user:id,name,username')
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if (! $isOwner) {
|
||||
$query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at');
|
||||
}
|
||||
|
||||
$query = match ($sort) {
|
||||
'trending' => $query->orderByDesc('ranking_score'),
|
||||
'rising' => $query->orderByDesc('heat_score'),
|
||||
'views' => $query->orderByDesc('view_count'),
|
||||
'favs' => $query->orderByDesc('favourite_count'),
|
||||
default => $query->orderByDesc('published_at'),
|
||||
};
|
||||
|
||||
$perPage = 24;
|
||||
$paginator = $query->cursorPaginate($perPage);
|
||||
|
||||
$data = collect($paginator->items())->map(function (Artwork $art) {
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
'published_at' => $art->published_at,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => $paginator->nextCursor()?->encode(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/favourites
|
||||
* Returns cursor-paginated favourites for the profile.
|
||||
*/
|
||||
public function favourites(Request $request, string $username): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('user_favorites')) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$perPage = 24;
|
||||
$cursor = $request->input('cursor');
|
||||
|
||||
$favIds = DB::table('user_favorites as uf')
|
||||
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->orderByDesc('uf.created_at')
|
||||
->offset($cursor ? (int) base64_decode($cursor) : 0)
|
||||
->limit($perPage + 1)
|
||||
->pluck('a.id');
|
||||
|
||||
$hasMore = $favIds->count() > $perPage;
|
||||
$favIds = $favIds->take($perPage);
|
||||
|
||||
if ($favIds->isEmpty()) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with('user:id,name,username')
|
||||
->whereIn('id', $favIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) {
|
||||
$art = $indexed[$id];
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => null, // Simple offset pagination for now
|
||||
'has_more' => $hasMore,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/stats
|
||||
* Returns profile statistics.
|
||||
*/
|
||||
public function stats(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$stats = null;
|
||||
if (Schema::hasTable('user_statistics')) {
|
||||
$stats = DB::table('user_statistics')->where('user_id', $user->id)->first();
|
||||
}
|
||||
|
||||
$followerCount = 0;
|
||||
if (Schema::hasTable('user_followers')) {
|
||||
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'stats' => $stats,
|
||||
'follower_count' => $followerCount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveUser(string $username): ?User
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user