Compare commits

...

58 Commits

Author SHA1 Message Date
29c3ff8572 update 2026-03-20 21:17:26 +01:00
1a62fcb81d categories v1 finished 2026-03-17 20:13:33 +01:00
7da0fd39f7 updated gallery 2026-03-17 18:34:26 +01:00
7b37259a2c feat: redesign private messaging inbox 2026-03-17 18:34:00 +01:00
2119741ba7 feat: add community activity feed and mentions 2026-03-17 18:26:57 +01:00
2728644477 feat: add tag discovery analytics and reporting 2026-03-17 18:23:38 +01:00
b3fc889452 feat: add captcha-backed forum security hardening 2026-03-17 16:06:28 +01:00
980a15f66e refactor: unify artwork card rendering 2026-03-17 14:49:20 +01:00
78151aabfe Remove legacy frontend assets and update gallery routes 2026-03-14 15:06:28 +01:00
4f576ceb04 more fixes 2026-03-12 07:22:38 +01:00
547215cbe8 remove unused assets 2026-03-09 19:17:58 +01:00
23b813bbff gitignore remove cpad 2026-03-09 18:09:57 +01:00
f6772f673b login update 2026-03-05 11:24:37 +01:00
5a33ca55a1 fixes 2026-03-03 21:00:18 +01:00
b9c2d8597d feat: Inertia profile settings page, Studio edit redesign, EGS, Nova UI components\n\n- Redesign /dashboard/profile as Inertia React page (Settings/ProfileEdit)\n with SettingsLayout sidebar, Nova UI components (TextInput, Textarea,\n Toggle, Select, RadioGroup, Modal, Button), avatar drag-and-drop,\n password change, and account deletion sections\n- Redesign Studio artwork edit page with two-column layout, Nova components,\n integrated TagPicker, and version history modal\n- Add shared MarkdownEditor component\n- Add Early-Stage Growth System (EGS): SpotlightEngine, FeedBlender,\n GridFiller, AdaptiveTimeWindow, ActivityLayer, admin panel\n- Fix upload category/tag persistence (V1+V2 paths)\n- Fix tag source enum, category tree display, binding resolution\n- Add settings.jsx Vite entry, settings.blade.php wrapper\n- Update ProfileController with JSON response support for API calls\n- Various route fixes (profile.edit, toolbar settings link)" 2026-03-03 20:57:43 +01:00
dc51d65440 feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
2026-03-03 09:48:31 +01:00
1266f81d35 feat: upload wizard refactor + vision AI tags + artwork versioning
Upload wizard:
- Refactored UploadWizard into modular steps (Step1FileUpload, Step2Details, Step3Publish)
- Extracted reusable hooks: useUploadMachine, useFileValidation, useVisionTags
- Extracted reusable components: CategorySelector, ContentTypeSelector
- Added TagPicker component (studio-style list picker with AI badge + new-tag insertion)
- Fixed TagInput auto-open bug (hasFocusedRef guard)
- Replaced TagInput with TagPicker in UploadSidebar

Vision AI tag suggestions:
- Add UploadVisionSuggestController: sync POST /api/uploads/{id}/vision-suggest
- Calls vision.klevze.net/analyze/all on upload completion (before step 2 opens)
- Two-phase useVisionTags: immediate gateway call + background DB polling
- Trigger fires on uploadReady (not step change) so tags arrive before user sees step 2
- Added vision.gateway config block with VISION_GATEWAY_URL env

Artwork versioning system:
- ArtworkVersion / ArtworkVersionEvent models
- ArtworkVersioningService: createNewVersion, restoreVersion, rate limiting, ranking decay
- Migrations: artwork_versions, artwork_version_events, versioning columns on artworks
- Studio API routes: GET versions, POST restore/{version_id}
- Feature tests: ArtworkVersioningTest (13 cases)
2026-03-01 14:56:46 +01:00
a875203482 feat: Nova UI component library + Studio dropdown/picker polish
- Add Nova UI library: Button, TextInput, Textarea, FormField, Select,
  NovaSelect, Checkbox, Radio/RadioGroup, Toggle, DatePicker,
  DateRangePicker, Modal + barrel index.js
- Replace all native <select> in Studio with NovaSelect (StudioFilters,
  StudioToolbar, BulkActionsBar) including frosted-glass portal and
  category group headers
- Replace native checkboxes in StudioGridCard, StudioTable, UploadSidebar,
  UploadWizard, Upload/Index with custom Checkbox component
- Add nova-scrollbar CSS utility (thin 4px, semi-transparent)
- Fix portal position drift: use viewport-relative coords (no scrollY offset)
  for NovaSelect, DatePicker and DateRangePicker
- Close portals on external scroll instead of remeasuring
- Improve hover highlight visibility in NovaSelect (bg-white/[0.13])
- Move search icon to right side in NovaSelect dropdown
- Reduce Studio layout top spacing (py-6 -> pt-4 pb-8)
- Add StudioCheckbox and SquareCheckbox backward-compat shims
- Add sync.sh rsync deploy script
2026-03-01 10:41:43 +01:00
e3ca845a6d Studio: make grid checkbox rectangular and commit table changes 2026-03-01 08:43:48 +01:00
211dc58884 Studio: use site CTA for Upload button (bg-sky-600) instead of accent 2026-03-01 08:30:06 +01:00
916bb29a53 feat(trending): switch trending endpoints to Ranking V2 ranking_score\n\n- discoverTrending() now sorts by ranking_score:desc + engagement_velocity:desc\n- HomepageService::getTrending() now sorts by ranking_score:desc + velocity\n- DB fallback joins artwork_stats for ranking_score sort\n- Both trending endpoints filter to last 30 days (spec §6)\n- Add created_at to Meilisearch filterableAttributes for date filtering\n- Synced index settings" 2026-02-28 16:47:08 +01:00
de3ec22ee5 feat: Ranking Engine V2 — intelligent scoring with shares, authority, decay & velocity\n\n- Add ArtworkRankingService with V2 formula:\n ranking_score = (base × authority × decay) + velocity_boost\n Base: views×0.2 + downloads×1.5 + favourites×2.5 + comments×3.0 + shares×4.0\n Authority: 1 + (log10(1+followers) + fav_received/1000) × 0.05\n Decay: 1 / (1 + hours/48)\n Velocity: 24h signals × velocity_weights × 0.5\n\n- Add nova:recalculate-rankings command (--chunk, --sync-rank-scores, --skip-index)\n- Add migration: ranking_score, engagement_velocity, shares/comments counts to artwork_stats\n- Upgrade RankingService.computeScores() with shares + comments + velocity\n- Update Meilisearch sortableAttributes: ranking_score, shares_count, engagement_velocity, comments_count\n- Update toSearchableArray() to expose V2 fields\n- Schedule every 30 min with overlap protection\n- Verified: 49733 artworks scored successfully" 2026-02-28 16:41:15 +01:00
90f244f264 feat: artwork share system with modal, native Web Share API, and tracking
- Add ArtworkShareModal with glassmorphism UI (Facebook, X, Pinterest, Email, Copy Link, Embed Code)
- Add ArtworkShareButton with lazy-loaded modal and native share fallback
- Add useWebShare hook abstracting navigator.share with AbortError handling
- Add ShareToast auto-dismissing notification component
- Add share() endpoint to ArtworkInteractionController (POST /api/artworks/{id}/share)
- Add artwork_shares migration for Phase 2 share tracking
- Refactor ArtworkActionBar to use new ArtworkShareButton component
2026-02-28 15:29:45 +01:00
568b3f3abb feat: merge Like+Favourite into single heart button, add Report modal with required reason & proof, fix favourite 422 (user_favorites -> artwork_favourites) 2026-02-28 15:15:37 +01:00
eee7df1f8c feat: artwork page carousels, recommendations, avatars & fixes
- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
2026-02-28 14:05:39 +01:00
80100c7651 fix: prevent toolbar search flicker on outside page click 2026-02-28 08:14:12 +01:00
8b00084f09 fix: separate pill clicks from carousel drag with bottom drag zone 2026-02-28 08:10:10 +01:00
6536d4ae78 feat: add reusable gallery carousel and ranking feed infrastructure 2026-02-28 07:56:25 +01:00
67ef79766c fix(gallery): fill tall portrait cards to full block width with object-cover crop
- ArtworkCard: add w-full to nova-card-media, use absolute inset-0 on img so
  object-cover fills the max-height capped box instead of collapsing the width
- MasonryGallery.css: add width:100% to media container, position img
  absolutely so top/bottom is cropped rather than leaving dark gaps
- Add React MasonryGallery + ArtworkCard components and entry point
- Add recommendation system: UserRecoProfile model/DTO/migration,
  SuggestedCreatorsController, SuggestedTagsController, Recommendation
  services, config/recommendations.php
- SimilarArtworksController, DiscoverController, HomepageService updates
- Update routes (api + web) and discover/for-you views
- Refresh favicon assets, update vite.config.js
2026-02-27 13:34:08 +01:00
09eadf9003 feat(artwork): sidebar layout, icon actions, original download URL fix
- ArtworkDownloadController: fix resolveDownloadUrl() to use correct CDN
  path: original/{h1}/{h2}/{hash}.{file_ext} (was wrong originals/h1/h2/h3/orig.webp)
  Wrap incrementDownloads() in try/catch so Redis failure can't break the response

- ArtworkPage: move ArtworkAuthor from left column to right sidebar
  Sidebar now stacks: Author → Actions → Awards (sticky top-24)
  Mobile block follows same order above main content

- ArtworkActions: replace four stacked text buttons with a compact 4-col icon grid
  Like (heart, rose when active), Save (star, amber when active),
  Share (network icon), Report (flag icon, red on hover)
  Download remains full-width orange CTA

- ArtworkAuthor: add icons to Profile (person) and Follow buttons
  Follow shows circle-check icon; Following state shows user-plus icon
2026-02-27 11:31:32 +01:00
4f9b43bbba feat(homepage): Nova homepage layout — guest/auth split, mascot category tiles, 5-col artwork grids
- HomeController: is_logged_in now lives inside props JSON (not separate view var)
- HomepageService: allForUser() adds user_data, fresh, suggested_creators; auth
  payload includes is_logged_in:true; guest payload merged with is_logged_in:false
- getTrending/getFreshUploads/getFollowingFeed: limit 12→10 (2 clean rows of 5)
- New getUserData() — unread messages + notifications counts via DB
- New getSuggestedCreators() — top followed-count creators not yet followed, cached 5 min

React — new components:
- HomeWelcomeRow: greeting bar with avatar, unread message/notification badges, upload CTA
- HomeFromFollowing: art grid from followed creators with empty-state
- HomeTrendingForYou: personalized grid adapts heading/link to user's top tag
- HomeBecauseYouLike: secondary personalized section keyed on top tag
- HomeSuggestedCreators: 4-col creator cards with avatar, stats, View Profile link
- HomeCTA: gradient upload banner; guest sees Create Account second CTA
- HomeCategories: 5-tile static category grid with mascot images

React — updated:
- HomePage: split into GuestHomePage + AuthHomePage; routes on is_logged_in prop
- HomeHero: isLoggedIn prop; upload href gates on auth; CTA → /discover/trending
- HomeTrending: see-all → /discover/trending; grid 4→5 cols; slice to multiple of 5
- HomeFresh: see-all → /discover/fresh; grid 4→5 cols; slice to multiple of 5
- HomeFromFollowing/TrendingForYou/BecauseYouLike: 5-col grid, slice to multiple of 5
- HomeCategories: mascots per category (wallpapers/photography/skins/other), smaller tiles
2026-02-27 10:48:35 +01:00
f0cca76eb3 storing analytics data 2026-02-27 09:46:51 +01:00
15b7b77d20 messages implemented 2026-02-26 21:12:32 +01:00
d0aefc5ddc feat: Nova homepage, profile redesign, and legacy view system overhaul
Homepage
- Add HomepageService with hero, trending (award-weighted), fresh uploads,
  popular tags, creator spotlight (weekly uploads ranking), and news sections
- Add React components: HomePage, HomeHero, HomeTrending, HomeFresh,
  HomeTags, HomeCreators, HomeNews (lazy-loaded below the fold)
- Wire home.blade.php with JSON props, SEO meta, JSON-LD, and hero preload
- Add HomePage.jsx to vite.config.js inputs

Profile page
- Hero banner with random user artwork as background + dark gradient overlay
- Favourites section uses real Artwork models + <x-artwork-card> for CDN URLs
- Newest artworks grid: gallery-grid → grid grid-cols-2 gap-4

Edit Profile page (user.blade.php)
- Add hero banner (featured wallpaper/photography via artwork_features,
  content_type_id IN [2,3]) sourced in UserController
- Remove bg-deep from outer wrapper; card backgrounds: bg-panel → bg-nova-800
- Remove stray AI-generated tag fragment from template

Author profile links
- Fix all /@username routes in: HomepageService, MonthlyCommentatorsController,
  LatestCommentsController, MyBuddiesController and corresponding blade views

Legacy view namespace
- Register View::addNamespace('legacy', resource_path('views/_legacy'))
  in AppServiceProvider::boot()
- Convert all view('legacy.x') and @include('legacy.x') calls to legacy::x
- Migrate legacy views to resources/views/_legacy/ with namespace support
2026-02-26 10:25:35 +01:00
d3fd32b004 fixes gallery 2026-02-26 07:27:20 +01:00
0032aec02f feat: increase gallery grid from 4 to 5 columns per row on desktopfeat: increase gallery grid from 4 to 5 columns per row on desktop 2026-02-25 19:11:23 +01:00
5c97488e80 fixed gallery 2026-02-22 17:09:34 +01:00
48e2055b6a gallery fix 2026-02-21 21:39:23 +01:00
e4e0bdf8f1 fixes 2026-02-21 19:26:48 +01:00
7648e7d426 Merge branch 'feature/RegistrationAntispamMail' into develop 2026-02-21 12:17:27 +01:00
e70a876ef2 Merge branch 'feat/registration-antispam-complete' into feature/RegistrationAntispamMail 2026-02-21 12:14:46 +01:00
df67252078 fix 2026-02-21 12:14:22 +01:00
b239af9619 feat(auth): complete registration anti-spam and quota hardening 2026-02-21 12:13:01 +01:00
4fb95c872b feat(auth): registration and login 2026-02-21 08:29:53 +01:00
795c7a835f Auth: convert auth views and verification email to Nova layout 2026-02-21 07:37:08 +01:00
93b009d42a Merge branch 'feature/forum-migration-v1' into develop 2026-02-19 08:36:46 +01:00
c30fa5a392 prepared and gallery fixes 2026-02-19 08:36:32 +01:00
8935065af1 feat(forum): add forum schema 2026-02-17 17:19:01 +01:00
41287914aa Upload beautify 2026-02-17 17:14:43 +01:00
b053c0cc48 fixed gallery from legacy into one 2026-02-15 17:46:08 +01:00
7dbfdab40e gallery 2026-02-15 16:49:15 +01:00
7734e53d87 fixed browse and tailwindcss style 2026-02-15 11:01:19 +01:00
d114472823 removed files 2026-02-15 09:47:24 +01:00
b2c9efe587 Merge branch 'feature/NovaDesignImplement' into develop 2026-02-15 09:25:23 +01:00
9dbe848412 Restore toolbar background to bg-nebula; add toolbar backdrop blur 2026-02-15 09:24:43 +01:00
79192345e3 Upload beautify 2026-02-14 15:14:12 +01:00
e129618910 toolbar fixed 2026-02-08 17:10:22 +01:00
f04854bb8d fixed toolbar menu 2026-02-08 16:43:29 +01:00
13045 changed files with 157517 additions and 855016 deletions

View File

@@ -0,0 +1,49 @@
Current DB & Models Analysis — 2026-02-10
Summary
- `content_types` is the master namespace (see screenshot). Rows: e.g. id=1 Photography (slug `photography`), id=2 Wallpapers, id=3 Skins, id=544 Members.
- `categories` references `content_types` through `content_type_id`; hierarchical parent/child relation via `parent_id`.
Observed DB columns (categories)
- id, content_type_id, parent_id, name, slug, description, image, is_active, sort_order, created_at, updated_at, deleted_at
Models verified
- `ContentType` (app/Models/ContentType.php)
- hasMany `categories()` and `rootCategories()`
- uses `slug` for route binding
- Status: OK and aligns with DB
- `Category` (app/Models/Category.php)
- belongsTo `contentType()`
- self-referential `parent()` / `children()` (ordered by `sort_order`, then `name`)
- `descendants()` recursive helper
- `seo()` relation, `artworks()` pivot
- scopes: `active()`, `roots()`
- accessors: `full_slug_path`, `url`, `canonical_url`, `breadcrumbs`
- slug validation enforced in `boot()` (lowercase; only a-z0-9- and dashes)
- Status: OK and consistent with screenshots
Key behaviors and checks
- URL formation: `$category->url` -> `/{content_type.slug}/{category_path}`; canonical URL -> `https://skinbase.org{url}`
- Slug policy: generation with `Str::slug()` + model validation. Do not bypass.
- Ordering: use `children()` (sort_order then name) for deterministic menus.
- Soft deletes: model uses `SoftDeletes`; be explicit when you need trashed categories.
- Eager-loading: `full_slug_path` walks parents — eager-load `parent` (or ancestors) to avoid N+1 when computing multiple paths.
Copilot / Dev rules (short checklist)
- Always look up content types by `slug`, not by numeric ID.
- Use `->roots()->active()->with('children')` for public category lists.
- Use `$category->url` and `$category->canonical_url` for links and canonical tags.
- Maintain slug rules: lowercase, only `a-z0-9-`.
- When reparenting categories, consider invalidating any cached derived paths for descendants.
- Avoid using legacy `artworks_categories` directly in new controllers; create an adapter if you must read old data.
Suggested next steps
- Add a PHPUnit test asserting slug validation and `url` generation for nested categories.
- (Optional) Generate a small ER diagram showing `content_types -> categories -> artwork_category`.
Files referenced
- [app/Models/ContentType.php](app/Models/ContentType.php)
- [app/Models/Category.php](app/Models/Category.php)
If you want, I can now add the PHPUnit test or generate the ER diagram.

8
.env.cpad Normal file
View File

@@ -0,0 +1,8 @@
# cPad Configuration
# Template: custom
CPAD_DEBUG=false
CPAD_CACHE_ENABLED=true
CPAD_LOG_LEVEL=WARNING
CPAD_SECURITY_LEVEL=MAXIMUM
CPAD_BACKUP_ENABLED=true

View File

@@ -45,6 +45,150 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database 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_STORE=database
# CACHE_PREFIX= # CACHE_PREFIX=
@@ -64,6 +208,23 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
# Registration anti-spam
REGISTRATION_IP_PER_MINUTE_LIMIT=3
REGISTRATION_IP_PER_DAY_LIMIT=20
REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6
REGISTRATION_EMAIL_COOLDOWN_MINUTES=30
REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24
REGISTRATION_ENABLE_TURNSTILE=true
REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true
REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2
REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30
REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30
REGISTRATION_MONTHLY_EMAIL_LIMIT=10000
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
TURNSTILE_TIMEOUT=5
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1
@@ -71,3 +232,49 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# ─── Early-Stage Growth System ───────────────────────────────────────────────
# Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to normal behaviour.
# NOVA_EARLY_GROWTH_MODE: off | light | aggressive
NOVA_EARLY_GROWTH_ENABLED=false
NOVA_EARLY_GROWTH_MODE=off
# Module toggles (only active when NOVA_EARLY_GROWTH_ENABLED=true)
NOVA_EGS_ADAPTIVE_WINDOW=true
NOVA_EGS_GRID_FILLER=true
NOVA_EGS_SPOTLIGHT=true
NOVA_EGS_ACTIVITY_LAYER=false
# AdaptiveTimeWindow thresholds
NOVA_EGS_UPLOADS_PER_DAY_NARROW=10
NOVA_EGS_UPLOADS_PER_DAY_WIDE=3
NOVA_EGS_WINDOW_NARROW_DAYS=7
NOVA_EGS_WINDOW_MEDIUM_DAYS=30
NOVA_EGS_WINDOW_WIDE_DAYS=90
# GridFiller minimum items per page
NOVA_EGS_GRID_MIN_RESULTS=12
# Auto-disable when site reaches organic scale
NOVA_EGS_AUTO_DISABLE=false
NOVA_EGS_AUTO_DISABLE_UPLOADS=50
NOVA_EGS_AUTO_DISABLE_USERS=500
# Cache TTLs (seconds)
NOVA_EGS_SPOTLIGHT_TTL=3600
NOVA_EGS_BLEND_TTL=300
NOVA_EGS_WINDOW_TTL=600
NOVA_EGS_ACTIVITY_TTL=1800
# ─── OAuth / Social Login ─────────────────────────────────────────────────────
# Google — https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback
# Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=/auth/discord/callback
# Apple — https://developer.apple.com/account/resources/identifiers/list/serviceId
# Apple sign in removed

4
.gitignore vendored
View File

@@ -16,6 +16,7 @@
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
/public/files
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor
@@ -24,3 +25,6 @@ Homestead.yaml
Thumbs.db Thumbs.db
/oldSite/* /oldSite/*
oldSite oldSite
packages
/packages/*
/public/admin/*

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

@@ -54,6 +54,388 @@ In order to ensure that the Laravel community is welcoming to all, please review
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## Vision & AI Auto-Tagging Integration
## Upload UI Feature Flag (`uploads.v2`)
The new React upload wizard is behind a feature flag and is **disabled by default**.
- Flag env var: `SKINBASE_UPLOADS_V2`
- Config key: `features.uploads_v2`
- Client flags source: `window.SKINBASE_FLAGS`
### Default behavior
- `SKINBASE_UPLOADS_V2=false` → legacy upload UI is rendered.
- `SKINBASE_UPLOADS_V2=true``UploadWizard` is rendered.
### Setup
In `.env` (or `.env.example` for project defaults):
```dotenv
SKINBASE_UPLOADS_V2=false
```
Enable explicitly when ready:
```dotenv
SKINBASE_UPLOADS_V2=true
```
After changing env values, clear/reload config as usual:
```bash
php artisan config:clear
```
The system intentionally keeps legacy upload as the default until the flag is explicitly turned on.
## Upload Moderation UI Flow
Admin moderation for draft uploads is available through a dedicated queue page.
- Page route: `/admin/uploads/moderation`
- Access: authenticated users with `role=admin` or `role=moderator`
- Data source: `GET /api/admin/uploads/pending`
### Queue behavior
1. The page loads pending draft uploads (`moderation_status=pending`).
2. Moderators can enter an optional note per upload.
3. Approve action calls:
- `POST /api/admin/uploads/{id}/approve`
- Sets moderation to approved and records moderator + timestamp.
4. Reject action calls:
- `POST /api/admin/uploads/{id}/reject`
- Sets upload status/processing state to rejected and stores note.
### Publish gate
- Normal users can publish only when `moderation_status=approved`.
- Admin users can publish with override behavior.
## Similar Artworks Analytics (A/B Evaluation)
The artwork page similar-items block emits two event types:
- `impression` (block rendered)
- `click` (item clicked)
Events are stored in `similar_artwork_events` and aggregated daily into `similar_artwork_daily_metrics` by `algo_version`.
- Ingest endpoint: `POST /api/analytics/similar-artworks`
- Aggregation command: `php artisan analytics:aggregate-similar-artworks --date=YYYY-MM-DD`
- Scheduler: runs daily at `03:10`
## Personalized Discovery Foundation (Phase 8)
This foundation adds versioned, async-only ingestion and profile normalization for personalized discovery.
- Tables:
- `user_interest_profiles`
- `user_discovery_events`
- `user_recommendation_cache`
- Ingest endpoint: `POST /api/discovery/events` (auth required)
- Supported event types: `view`, `click`, `favorite`, `download`
- Processing model: non-blocking queue job (`IngestUserDiscoveryEventJob`)
- Normalization: recency-decay + score normalization in `UserInterestProfileService`
No feed ranking/UI behavior is introduced in this foundation step.
### Feed Endpoint Skeleton
The backend now exposes a personalized feed API skeleton:
- Endpoint: `GET /api/v1/feed` (auth required)
- Query params:
- `limit` (1-50, default 24)
- `cursor` (opaque cursor token for pagination)
- `algo_version` (optional override)
- Response includes `data` items and `meta.next_cursor` for cursor pagination.
Behavior:
- Reads `user_recommendation_cache` by `user_id + algo_version`.
- On cache miss/stale, returns immediate fallback results and dispatches async regeneration job.
- Regeneration runs in queue (`RegenerateUserRecommendationCacheJob`) and writes refreshed cache.
- Includes cold-start fallback (`popular + similar`) and a diversity guard to avoid near-duplicates.
## Feed Analytics Instrumentation
Feed analytics now track:
- `feed_impression`
- `feed_click`
Payload dimensions:
- `user_id` (derived from auth session)
- `artwork_id`
- `position`
- `algo_version`
- `source` (`personalized`, `cold_start`, `fallback`)
Optional:
- `dwell_seconds` (for click dwell bucket metrics)
Endpoints:
- Ingest: `POST /api/analytics/feed` (auth required)
- Daily aggregation: `php artisan analytics:aggregate-feed --date=YYYY-MM-DD`
- Admin report: `GET /api/admin/reports/feed-performance`
Daily metrics include CTR, save-rate, and dwell buckets.
For non-blocking client transport, use `navigator.sendBeacon` with `fetch(..., { keepalive: true })` fallback.
Reference helper: `resources/js/lib/feedAnalytics.js`.
## Phase 8B: Ranking Weight Tuning (Manual + Data-Driven)
Discovery ranking now supports versioned blend weights per `algo_version` in `config/discovery.php`.
- Blend terms: `w1` interest, `w2` recency, `w3` popularity, `w4` novelty
- Per-algo sets: `discovery.ranking.algo_weight_sets`
- Safe rollout: deterministic traffic split by `algo_version` with config gates (`g10`, `g50`, `g100`)
- Emergency rollback: `DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1`
Offline evaluator and A/B helper:
- Evaluate objective across one/all algos:
- `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD`
- Optional: `--algo=clip-cosine-v1`
- Baseline vs candidate comparison:
- `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD`
Objective score uses `feed_daily_metrics` and configurable objective weights in `discovery.evaluation.objective_weights`.
Temporary production policy: set `DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true` to keep `save_rate` visible but excluded from objective score until save-event ingestion is verified.
Operational runbook: `docs/feed-rollout-runbook.md`.
## Operations / Runbooks
- Upload UI v2 rollout, post-deploy monitoring, and rollback: `docs/ui/upload-v2-rollout-runbook.md`
- Feed rollout and rollback: `docs/feed-rollout-runbook.md`
- Registration anti-spam and email quota protection: `docs/registration-antispam.md`
No automatic tuning is enabled in this phase.
Skinbase uses asynchronous AI tagging via `AutoTagArtworkJob`.
The job calls external vision services (CLIP and optional YOLO), normalizes tags, and attaches them through `TagService` as AI tags with confidence values.
### Critical Safety Rule
⚠️ **Publish must never depend on vision services.**
- Upload/publish flow dispatches AI tagging to queue after publish work.
- Vision failures, timeouts, or service outages must not block artwork publish.
- If AI tagging fails, artwork remains published and can be tagged later (retry/manual/batch).
### Environment Variables (Vision)
Set these in `.env` (all are optional; defaults are in `config/vision.php`):
#### Global
- `VISION_ENABLED` (default: `true`)
- Master switch for all AI auto-tagging.
- `VISION_QUEUE` (default: `default`)
- Queue name used by `AutoTagArtworkJob`.
- `VISION_IMAGE_VARIANT` (default: `md`)
- Derivative variant sent to vision services (e.g. `md`, `lg`).
#### CLIP
- `CLIP_BASE_URL` (default: empty)
- Base URL for CLIP service (example: `https://clip.internal`).
- If empty, CLIP call is skipped.
- `CLIP_ANALYZE_ENDPOINT` (default: `/analyze`)
- Path appended to `CLIP_BASE_URL`.
- `CLIP_TIMEOUT_SECONDS` (default: `8`)
- Request timeout for CLIP calls.
- `CLIP_CONNECT_TIMEOUT_SECONDS` (default: `2`)
- Connection timeout for CLIP calls.
- `CLIP_HTTP_RETRIES` (default: `1`)
- HTTP retry attempts for CLIP requests.
- `CLIP_HTTP_RETRY_DELAY_MS` (default: `200`)
- Delay between CLIP retries.
#### YOLO (optional)
- `YOLO_ENABLED` (default: `true`)
- Enables YOLO integration.
- `YOLO_BASE_URL` (default: empty)
- Base URL for YOLO service. If empty, YOLO call is skipped.
- `YOLO_ANALYZE_ENDPOINT` (default: `/analyze`)
- Path appended to `YOLO_BASE_URL`.
- `YOLO_TIMEOUT_SECONDS` (default: `8`)
- Request timeout for YOLO calls.
- `YOLO_CONNECT_TIMEOUT_SECONDS` (default: `2`)
- Connection timeout for YOLO calls.
- `YOLO_HTTP_RETRIES` (default: `1`)
- HTTP retry attempts for YOLO requests.
- `YOLO_HTTP_RETRY_DELAY_MS` (default: `200`)
- Delay between YOLO retries.
- `YOLO_PHOTOGRAPHY_ONLY` (default: `true`)
- When `true`, YOLO is called only for artworks in photography content type.
### Expected CLIP Response Format
CLIP `/analyze` should return tags as either a direct list or under `tags` / `data`:
```json
[
{ "tag": "cyberpunk", "confidence": 0.42 },
{ "tag": "city", "confidence": 0.31 }
]
```
Also accepted:
```json
{
"tags": [
{ "tag": "cyberpunk", "confidence": 0.42 }
]
}
```
or
```json
{
"data": [
{ "tag": "cyberpunk", "confidence": 0.42 }
]
}
```
### Expected YOLO Response Format
YOLO may return the same tag list format as CLIP, or object detections:
```json
{
"objects": [
{ "label": "person", "confidence": 0.91 },
{ "label": "camera", "confidence": 0.67 }
]
}
```
`label` values are converted to tags, confidence is preserved when present.
### AutoTagArtworkJob Behavior
- Calls CLIP `/analyze` when `VISION_ENABLED=true` and `CLIP_BASE_URL` is set.
- Optionally calls YOLO based on `YOLO_ENABLED` and `YOLO_PHOTOGRAPHY_ONLY`.
- Merges CLIP + YOLO tags and keeps highest confidence for duplicates.
- Normalizes tags before attach (lowercase, cleanup, slug-safe format).
- Uses `TagService::attachAiTags()` to store pivot data:
- `source = ai`
- `confidence = <float|null>`
- Runs with queue retry + timeout safety (`tries`, `backoff`, `timeout`).
- Logs failures with reference/context for troubleshooting.
- On non-retriable response scenarios (e.g. 4xx), job exits safely without blocking publish.
### Queue / Worker Requirements (`VISION_QUEUE`)
- Ensure a worker is running for the configured queue.
- Example worker command:
```bash
php artisan queue:work --queue=default
```
- If `VISION_QUEUE=vision`, run worker for that queue:
```bash
php artisan queue:work --queue=vision
```
- In production, use Supervisor/systemd/Horizon to keep workers alive.
- Without an active worker, auto-tagging jobs remain queued and will not execute.
### Local vs Production Notes
#### Local development
- For fully offline local work, set `VISION_ENABLED=false`.
- Or set only `CLIP_BASE_URL`/`YOLO_BASE_URL` you can reach locally.
- Prefer short timeouts to avoid slow dev feedback loops.
#### Production
- Use internal/private service endpoints for CLIP/YOLO when possible.
- Keep conservative timeouts and low retry counts to prevent queue congestion.
- Monitor failed jobs and logs for vision service reliability.
- Scale queue workers based on upload volume and service latency.
### Verify Setup (Health + Test Call)
After configuring env vars and restarting workers, verify in this order:
Quick helper (PowerShell):
```powershell
pwsh -File ./scripts/vision-smoke.ps1
```
Optional flags:
```powershell
pwsh -File ./scripts/vision-smoke.ps1 -EnvFile ".env" -SampleImageUrl "https://files.skinbase.org/img/aa/bb/cc/md.webp"
pwsh -File ./scripts/vision-smoke.ps1 -SkipAnalyze
```
1. Confirm queue worker is consuming `VISION_QUEUE`.
```bash
php artisan queue:work --queue=default
```
1. Check CLIP/YOLO health endpoints (replace host/port as needed):
```bash
curl -fsS "$CLIP_BASE_URL/health"
curl -fsS "$YOLO_BASE_URL/health"
```
1. Make a direct analyze test call (CLIP example):
```bash
curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \
-H "Content-Type: application/json" \
-d '{"image_url":"https://files.skinbase.org/img/aa/bb/cc/md.webp"}'
```
1. Trigger an upload/publish and confirm:
- Publish response succeeds even if CLIP/YOLO is down.
- `AutoTagArtworkJob` is queued/executed asynchronously.
- AI tags appear on the artwork when services are healthy.
- Failures are logged, but publish is unaffected.
## Queue workers
The contact form mails are queued. To process them you need a worker. Locally you can run a foreground worker:
```
php artisan queue:work --sleep=3 --tries=3
```
For production we provide example configs under `deploy/`:
- `deploy/supervisor/skinbase-queue.conf` — Supervisor config
- `deploy/systemd/skinbase-queue.service` — systemd unit file
See `docs/QUEUE.md` for full setup steps and commands.
## License ## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

8
TODO.md Normal file
View File

@@ -0,0 +1,8 @@
# TODO SKINBASE NOVA
## FORUM
- [ ] we need to add in a main search (toolbar) and a search in the forum (search bar in the forum page)
## ARTWORKS
- [ ] http://skinbase26.test/art/69601/testna-slika => we shouldnt display follow for yourself

View File

@@ -6,11 +6,11 @@ class Banner
{ {
public static function ShowResponsiveAd() public static function ShowResponsiveAd()
{ {
echo '<div class="responsive_ad">'; #echo '<div class="responsive_ad">';
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>'; #echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>'; #echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>'; #echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
echo '</div>'; #echo '</div>';
} }
public static function ShowBanner300x250() public static function ShowBanner300x250()

View File

@@ -81,7 +81,8 @@ class Chat
echo '<div class="row well">'; echo '<div class="row well">';
if (!empty($_SESSION['web_login']['status'])) { if (!empty($_SESSION['web_login']['status'])) {
echo '<form action="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '" method="post">'; echo '<form action="' . htmlspecialchars(route('community.chat'), ENT_QUOTES, 'UTF-8') . '" method="post">';
echo csrf_field();
echo '<div class="col-sm-10">'; echo '<div class="col-sm-10">';
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">'; echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
echo '</div>'; echo '</div>';

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

View File

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

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class AggregateTagInteractionAnalyticsCommand extends Command
{
protected $signature = 'analytics:aggregate-tag-interactions {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate tag interaction analytics into daily metrics by surface, tag, source tag, and query';
public function handle(): int
{
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$normalizedTag = "COALESCE(tag_slug, '')";
$normalizedSourceTag = "COALESCE(source_tag_slug, '')";
$normalizedQuery = "LOWER(TRIM(COALESCE(query, '')))";
$rows = DB::table('tag_interaction_events')
->selectRaw('surface')
->selectRaw("{$normalizedTag} AS tag_slug")
->selectRaw("{$normalizedSourceTag} AS source_tag_slug")
->selectRaw("{$normalizedQuery} AS query")
->selectRaw('COUNT(*) AS clicks')
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
->selectRaw('AVG(position) AS avg_position')
->whereDate('event_date', $date)
->where('event_type', 'click')
->groupBy('surface', DB::raw($normalizedTag), DB::raw($normalizedSourceTag), DB::raw($normalizedQuery))
->get();
DB::transaction(function () use ($date, $rows): void {
DB::table('tag_interaction_daily_metrics')
->where('metric_date', $date)
->delete();
$payload = $rows->map(static function ($row) use ($date): array {
return [
'metric_date' => $date,
'surface' => (string) $row->surface,
'tag_slug' => trim((string) ($row->tag_slug ?? '')),
'source_tag_slug' => trim((string) ($row->source_tag_slug ?? '')),
'query' => trim((string) ($row->query ?? '')),
'clicks' => (int) ($row->clicks ?? 0),
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
'created_at' => now(),
'updated_at' => now(),
];
})->all();
foreach (array_chunk($payload, 500) as $chunk) {
if ($chunk !== []) {
DB::table('tag_interaction_daily_metrics')->insert($chunk);
}
}
});
$this->info("Aggregated tag interaction analytics for {$date}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\TagService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Generate AI tags for artworks using a local LM Studio vision model.
*
* Usage:
* php artisan artworks:ai-tag
* php artisan artworks:ai-tag --after-id=1000 --chunk=20 --dry-run
* php artisan artworks:ai-tag --limit=100 --skip-tagged
* php artisan artworks:ai-tag --artwork-id=242 # process a single artwork by ID
* php artisan artworks:ai-tag --artwork-id=242 --dump-curl # print equivalent curl command (no API call made)
* php artisan artworks:ai-tag --artwork-id=242 --debug # print CDN URL, file size, magic bytes and data-URI prefix
* php artisan artworks:ai-tag --url=http://192.168.1.5:8200 --model=google/gemma-3-4b
*/
final class AiTagArtworksCommand extends Command
{
protected $signature = 'artworks:ai-tag
{--artwork-id= : Process only this single artwork ID (bypasses public/approved scope)}
{--after-id=0 : Skip artworks with ID this value (useful for resuming)}
{--limit= : Stop after processing this many artworks}
{--chunk=50 : DB chunk size}
{--dry-run : Print tags but do not persist them}
{--skip-tagged : Skip artworks that already have at least one AI tag}
{--url-only : Send CDN URL instead of base64 (only works if LM Studio can reach the CDN)}
{--dump-curl : Print the equivalent curl command for the API call and skip the actual request}
{--debug : Print CDN URL, file size, magic bytes and data-URI prefix for each image}
{--url= : LM Studio base URL (overrides config/env)}
{--model= : Model identifier (overrides config/env)}
{--clear-ai-tags : Delete existing AI tags for each artwork before re-tagging}
';
protected $description = 'Generate tags for artworks via a local LM Studio vision model';
// -------------------------------------------------------------------------
// Prompt
// -------------------------------------------------------------------------
private const SYSTEM_PROMPT = <<<'PROMPT'
You are a precise visual-art tagging engine for an artwork gallery.
Your task is to analyse an artwork image and generate high-quality search tags that are useful for discovery, filtering, and categorisation.
Prioritise tags that are:
- visually evident in the image
- concise and specific
- useful for gallery search
Prefer concrete visual concepts over vague opinions.
Do not invent details that are not clearly visible.
Do not include artist names, brands, watermarks, or assumptions about intent unless directly visible.
Return tags that describe:
- subject or scene
- art style or genre
- mood or atmosphere
- colour palette
- technique or medium if visually apparent
- composition or notable visual elements if relevant
Avoid:
- generic filler tags like "beautiful", "nice", "art", "image"
- duplicate or near-duplicate tags
- full sentences
- overly broad tags when a more specific one is visible
Output must be deterministic, compact, and consistent.
PROMPT;
private const USER_PROMPT = <<<'PROMPT'
Analyse this artwork image and return a JSON array of relevant tags.
Requirements:
- Return ONLY a valid JSON array of lowercase strings.
- No markdown, no explanation, no extra text.
- Output between 8 and 14 tags.
- Each tag must be 1 to 3 words.
- Use only letters, numbers, spaces, and hyphens.
- Do not end tags with punctuation.
- Do not include duplicate or near-duplicate tags.
- Order tags from most important to least important.
Focus on tags from these groups when visible:
1. main subject or scene
2. style or genre
3. mood or atmosphere
4. dominant colours
5. medium or technique
6. notable visual elements or composition
Tagging guidelines:
- Prefer specific tags over generic ones.
- Use searchable gallery-style tags.
- Include only what is clearly visible or strongly implied by the image.
- If the artwork is abstract, prioritise style, colour, mood, and composition.
- If the artwork is representational, prioritise subject, setting, style, and mood.
- If a detail is uncertain, leave it out.
Good output example:
["fantasy portrait","digital painting","female warrior","blue tones","dramatic lighting","glowing eyes","cinematic mood","detailed armor"]
Bad output example:
["art","beautiful image","very cool fantasy woman","amazing colors","masterpiece"]
Now return only the JSON array.
PROMPT;
// -------------------------------------------------------------------------
public function __construct(private readonly TagService $tagService)
{
parent::__construct();
}
public function handle(): int
{
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
$afterId = max(0, (int) $this->option('after-id'));
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$chunk = max(1, min((int) $this->option('chunk'), 200));
$dryRun = (bool) $this->option('dry-run');
$skipTagged = (bool) $this->option('skip-tagged');
$dumpCurl = (bool) $this->option('dump-curl');
$verbose = (bool) $this->option('debug');
$useBase64 = !(bool) $this->option('url-only');
$clearAiTags = (bool) $this->option('clear-ai-tags');
$baseUrl = rtrim((string) ($this->option('url') ?: config('vision.lm_studio.base_url')), '/');
$model = (string) ($this->option('model') ?: config('vision.lm_studio.model'));
$maxTags = (int) config('vision.lm_studio.max_tags', 12);
$this->info("LM Studio : {$baseUrl}");
$this->info("Model : {$model}");
$this->info("Image mode : " . ($useBase64 ? 'base64 (default)' : 'CDN URL (--url-only)'));
$this->info("Dry run : " . ($dryRun ? 'YES' : 'no'));
$this->info("Clear AI : " . ($clearAiTags ? 'YES — existing AI tags deleted first' : 'no'));
if ($artworkId !== null) {
$this->info("Artwork ID : {$artworkId} (single-artwork mode)");
}
$this->line('');
// Single-artwork mode: bypass public/approved scope so any artwork can be tested.
if ($artworkId !== null) {
$artwork = Artwork::withTrashed()->find($artworkId);
if ($artwork === null) {
$this->error("Artwork #{$artworkId} not found.");
return self::FAILURE;
}
$limit = 1;
$query = Artwork::withTrashed()->where('id', $artworkId);
} else {
$query = Artwork::query()
->public()
->where('id', '>', $afterId)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->orderBy('id');
if ($skipTagged) {
// Exclude artworks that already have an AI-sourced tag in the pivot.
$query->whereDoesntHave('tags', fn ($q) => $q->where('artwork_tag.source', 'ai'));
}
}
$processed = 0;
$tagged = 0;
$skipped = 0;
$errors = 0;
$query->chunkById($chunk, function ($artworks) use (
&$processed, &$tagged, &$skipped, &$errors,
$limit, $dryRun, $dumpCurl, $verbose, $useBase64, $baseUrl, $model, $maxTags, $clearAiTags,
) {
foreach ($artworks as $artwork) {
if ($limit !== null && $processed >= $limit) {
return false; // stop iteration
}
$processed++;
$imageUrl = $artwork->thumbUrl('md');
if ($imageUrl === null) {
$this->warn(" [#{$artwork->id}] No thumb URL — skip");
$skipped++;
continue;
}
$this->line(" [#{$artwork->id}] {$artwork->title}");
// Remove AI tags first if requested.
if ($clearAiTags) {
$aiTagIds = DB::table('artwork_tag')
->where('artwork_id', $artwork->id)
->where('source', 'ai')
->pluck('tag_id')
->all();
if ($aiTagIds !== []) {
if (!$dryRun) {
$this->tagService->detachTags($artwork, $aiTagIds);
}
$this->line(' ✂ Cleared ' . count($aiTagIds) . ' existing AI tag(s)' . ($dryRun ? ' (dry-run)' : ''));
}
}
if ($verbose) {
$this->line(" CDN URL : {$imageUrl}");
}
try {
$tags = $this->fetchTags($baseUrl, $model, $imageUrl, $useBase64, $maxTags, $dumpCurl, $verbose);
} catch (Throwable $e) {
$this->error(" ✗ API error: " . $e->getMessage());
// Show first 120 chars of the response body for easier debugging.
if (str_contains($e->getMessage(), 'status code')) {
$this->line(" (use --dry-run to test without saving)");
}
Log::error('artworks:ai-tag API error', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
$errors++;
continue;
}
if ($tags === []) {
$this->warn(" ✗ No tags returned");
$skipped++;
continue;
}
$tagList = implode(', ', $tags);
$this->line("{$tagList}");
if (!$dryRun) {
$aiTagPayload = array_map(fn (string $t) => ['tag' => $t, 'confidence' => null], $tags);
try {
$this->tagService->attachAiTags($artwork, $aiTagPayload);
$tagged++;
} catch (Throwable $e) {
$this->error(" ✗ Save error: " . $e->getMessage());
Log::error('artworks:ai-tag save error', [
'artwork_id' => $artwork->id,
'error' => $e->getMessage(),
]);
$errors++;
}
} else {
$tagged++;
}
}
});
$this->line('');
$this->info("Done. processed={$processed} tagged={$tagged} skipped={$skipped} errors={$errors}");
return $errors > 0 ? self::FAILURE : self::SUCCESS;
}
// -------------------------------------------------------------------------
// LM Studio API call
// -------------------------------------------------------------------------
/**
* @return list<string>
*/
private function fetchTags(
string $baseUrl,
string $model,
string $imageUrl,
bool $useBase64,
int $maxTags,
bool $dumpCurl = false,
bool $verbose = false,
): array {
$imageContent = $useBase64
? $this->buildBase64ImageContent($imageUrl, $verbose)
: ['type' => 'image_url', 'image_url' => ['url' => $imageUrl]];
$payload = [
'model' => $model,
'temperature' => (float) config('vision.lm_studio.temperature', 0.3),
'max_tokens' => (int) config('vision.lm_studio.max_tokens', 300),
'messages' => [
[
'role' => 'system',
'content' => self::SYSTEM_PROMPT,
],
[
'role' => 'user',
'content' => [
$imageContent,
['type' => 'text', 'text' => self::USER_PROMPT],
],
],
],
];
$timeout = (int) config('vision.lm_studio.timeout', 60);
$connectTimeout = (int) config('vision.lm_studio.connect_timeout', 5);
$endpoint = "{$baseUrl}/v1/chat/completions";
// --dump-curl: write payload to a temp file and print the equivalent curl command.
if ($dumpCurl) {
$jsonPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// Truncate any base64 data URIs in the printed output so the terminal stays readable.
$printable = preg_replace(
'/("data:[^;]+;base64,)([A-Za-z0-9+\/=]{60})[A-Za-z0-9+\/=]+(")/',
'$1$2...[base64 truncated]$3',
$jsonPayload,
) ?? $jsonPayload;
// Write the full (untruncated) payload to a temp file for use with curl --data.
$tmpJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_payload_' . uniqid() . '.json';
file_put_contents($tmpJson, $jsonPayload);
$this->line('');
$this->line('<fg=yellow>--- Payload (base64 truncated for display) ---</>');
$this->line($printable);
$this->line('');
$this->line('<fg=yellow>--- curl command (full payload in temp file) ---</>');
$this->line(
'curl -s -X POST ' . escapeshellarg($endpoint)
. ' -H ' . escapeshellarg('Content-Type: application/json')
. ' --data @' . escapeshellarg($tmpJson)
. ' | python -m json.tool'
);
$this->line('');
$this->info("Full JSON payload written to: {$tmpJson}");
// Return empty — no real API call is made.
return [];
}
$response = Http::timeout($timeout)
->connectTimeout($connectTimeout)
->post($endpoint, $payload)
->throw();
$body = $response->json();
$content = $body['choices'][0]['message']['content'] ?? '';
return $this->parseTags((string) $content, $maxTags);
}
/**
* Download the image using the system curl binary (raw bytes, no encoding surprises),
* base64-encode from the local file, then delete it.
*
* Using curl directly is more reliable than the Laravel Http client here because it
* avoids gzip/deflate decoding issues, chunked-transfer quirks, and header parsing
* edge cases that could corrupt the image bytes before encoding.
*
* @return array<string, mixed>
* @throws \RuntimeException if curl fails or the file is empty
*/
private function buildBase64ImageContent(string $imageUrl, bool $verbose = false): array
{
$ext = strtolower(pathinfo(parse_url($imageUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
$mime = match ($ext) {
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
default => 'image/jpeg',
};
$tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_' . uniqid() . '.' . ($ext ?: 'jpg');
try {
exec(
'curl -s -f -L --max-time 30 -o ' . escapeshellarg($tmpPath) . ' ' . escapeshellarg($imageUrl),
$output,
$exitCode,
);
if ($exitCode !== 0 || !file_exists($tmpPath) || filesize($tmpPath) === 0) {
throw new \RuntimeException("curl failed to download image (exit={$exitCode}, size=" . (file_exists($tmpPath) ? filesize($tmpPath) : 'N/A') . "): {$imageUrl}");
}
$rawBytes = file_get_contents($tmpPath);
if ($rawBytes === false || $rawBytes === '') {
throw new \RuntimeException("file_get_contents returned empty after curl download: {$tmpPath}");
}
// LM Studio does not support WebP. Convert to JPEG via GD if needed.
if ($mime === 'image/webp') {
$convertedPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_conv_' . uniqid() . '.jpg';
try {
if (!function_exists('imagecreatefromwebp')) {
throw new \RuntimeException('GD extension with WebP support is required to convert WebP images. Enable ext-gd with WebP support in php.ini.');
}
$img = imagecreatefromwebp($tmpPath);
if ($img === false) {
throw new \RuntimeException("GD failed to load WebP: {$tmpPath}");
}
imagejpeg($img, $convertedPath, 92);
imagedestroy($img);
$rawBytes = file_get_contents($convertedPath);
$mime = 'image/jpeg';
if ($verbose) {
$this->line(' Convert : WebP → JPEG (LM Studio does not accept WebP)');
}
} finally {
@unlink($convertedPath);
}
}
if ($verbose) {
$fileSize = filesize($tmpPath);
// Show first 8 bytes as hex to confirm it's a real image, not an HTML error page.
$magicHex = strtoupper(bin2hex(substr($rawBytes, 0, 8)));
$this->line(" File : {$tmpPath}");
$this->line(" Size : {$fileSize} bytes");
$this->line(" Magic : {$magicHex} (JPEG=FFD8FF, PNG=89504E47, WEBP=52494646)");
}
$base64 = base64_encode($rawBytes);
$dataUri = "data:{$mime};base64,{$base64}";
if ($verbose) {
$this->line(" MIME : {$mime}");
$this->line(" URI pfx : " . substr($dataUri, 0, 60) . '...');
}
} finally {
@unlink($tmpPath);
}
return ['type' => 'image_url', 'image_url' => ['url' => $dataUri]];
}
// -------------------------------------------------------------------------
// Response parsing
// -------------------------------------------------------------------------
/**
* Extract a JSON array from the model's response text.
*
* The model should return just the array, but may include surrounding text
* or markdown code fences, so we search for the first `[…]` block.
*
* @return list<string>
*/
private function parseTags(string $content, int $maxTags): array
{
$content = trim($content);
// Strip markdown code fences if present (```json … ```)
$content = preg_replace('/^```(?:json)?\s*/i', '', $content) ?? $content;
$content = preg_replace('/\s*```$/', '', $content) ?? $content;
// Extract the first JSON array from the text.
if (!preg_match('/(\[.*?\])/s', $content, $matches)) {
return [];
}
$decoded = json_decode($matches[1], true);
if (!is_array($decoded)) {
return [];
}
$tags = [];
foreach ($decoded as $item) {
if (!is_string($item)) {
continue;
}
$clean = trim(strtolower((string) $item));
if ($clean !== '') {
$tags[] = $clean;
}
}
// Respect the configured max-tags ceiling.
return array_slice(array_unique($tags), 0, $maxTags);
}
}

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

View File

@@ -0,0 +1,415 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use App\Models\User;
use App\Models\UserProfile;
use Intervention\Image\ImageManagerStatic as Image;
use Carbon\Carbon;
class AvatarsMigrate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatars:migrate
{--dry-run : Do not write files or update database}
{--force : Overwrite existing migrated avatars}
{--remove-legacy : Remove legacy files after successful migration}
{--path=public/files/usericons : Legacy path to scan}
{--user-id= : Only migrate a single user by ID}
';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate legacy avatars from public/files/usericons to storage/app/public/avatars and generate sizes (WebP)';
/**
* Allowed MIME types for source images.
*
* @var array
*/
protected $allowed = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
/**
* Target sizes to generate.
*
* @var int[]
*/
protected $sizes = [32, 40, 64, 80, 96, 128, 256, 512];
public function handle(): int
{
$dry = $this->option('dry-run');
$force = $this->option('force');
$removeLegacy = $this->option('remove-legacy');
$legacyPath = base_path($this->option('path'));
$userId = $this->option('user-id') ? (int) $this->option('user-id') : null;
$verbose = $this->output->isVerbose();
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : ''));
// Detect processing backend: Intervention preferred, GD fallback
$useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic');
if ($useIntervention) {
Image::configure(['driver' => extension_loaded('imagick') ? 'imagick' : 'gd']);
}
$bar = null;
$query = User::with('profile');
if ($userId) {
$query->where('id', $userId);
}
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention, $verbose) {
foreach ($users as $user) {
/** @var UserProfile|null $profile */
$profile = $user->profile;
if (!$profile) {
continue;
}
// Skip if already migrated unless --force
if (!$force && !empty($profile->avatar_hash)) {
$this->line("[skip] user={$user->id} already migrated");
continue;
}
$source = $this->findLegacyFile($profile, $user->id, $legacyPath, 'legacy');
//dd($source);
if (!$source) {
if ($verbose) {
$this->line("[noop] user={$user->id} no legacy file found");
}
continue;
}
try {
$this->line("[proc] user={$user->id} file={$source}");
if ($useIntervention) {
$img = Image::make($source);
$mime = $img->mime();
} else {
$info = @getimagesize($source);
$mime = $info['mime'] ?? null;
}
if (!in_array($mime, $this->allowed, true)) {
$this->line("[reject] user={$user->id} unsupported mime={$mime}");
continue;
}
// Re-encode full original to webp (strip metadata)
if ($useIntervention) {
$originalBlob = (string) $img->encode('webp', 82);
} else {
$originalBlob = $this->gdEncodeWebp($source, 82);
}
// Hybrid hash: deterministic user-id fingerprint + short content fingerprint
// idPart = sha1(zero-padded user id), contentPart = first 12 chars of sha1(original webp blob)
$idPart = sha1(sprintf('%08d', $user->id));
$contentPart = substr(sha1($originalBlob), 0, 12);
$hash = sprintf('%s_%s', $idPart, $contentPart);
// Precompute storage dir for dry-run and real run
$hashPrefix1 = substr($hash, 0, 2);
$hashPrefix2 = substr($hash, 2, 2);
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
// CDN base for public URLs
$cdnBase = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
if ($dry) {
$absPathDry = Storage::disk('public')->path("{$dir}/original.webp");
$publicUrlDry = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
$this->line("[dry] user={$user->id} would write avatars for hash={$hash} path={$absPathDry} url={$publicUrlDry}");
} else {
// Save original.webp
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
// Generate sizes
foreach ($this->sizes as $size) {
if ($useIntervention) {
$thumb = Image::make($source)->fit($size, $size, function ($constraint) {
$constraint->upsize();
});
$thumbBlob = (string) $thumb->encode('webp', 82);
} else {
$thumbBlob = $this->gdCreateThumbnailWebp($source, $size, 82);
}
Storage::disk('public')->put("{$dir}/{$size}.webp", $thumbBlob);
}
// Update DB
$profile->avatar_hash = $hash;
$profile->avatar_mime = 'image/webp';
$profile->avatar_updated_at = Carbon::now();
$profile->save();
$absPath = Storage::disk('public')->path("{$dir}/original.webp");
$publicUrl = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
$this->line("[ok] user={$user->id} migrated hash={$hash} path={$absPath} url={$publicUrl}");
if ($removeLegacy && !empty($profile->avatar_legacy)) {
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
if (file_exists($legacyFile)) {
@unlink($legacyFile);
$this->line("[rm] removed legacy file {$legacyFile}");
}
}
}
} catch (\Exception $e) {
$this->error("[error] user={$user->id} {$e->getMessage()}");
continue;
}
}
});
$this->info('Avatar migration complete');
return 0;
}
/**
* Try to find a legacy avatar file for a user/profile.
*
* @param UserProfile $profile
* @param int $userId
* @param string $legacyBase
* @return string|null
*/
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase, ?string $legacyConnection = null): ?string
{
$avatar = DB::connection('legacy')->table('users')->where('user_id', $userId)->value('icon');
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $avatar;
if (file_exists($p)) {
return $p;
}
}
// 1) If profile->avatar_legacy looks like a filename, try it
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
if (file_exists($p)) {
return $p;
}
}
// 2) Try files named by user id with common extensions
$exts = ['png','jpg','jpeg','webp','gif'];
foreach ($exts as $ext) {
$p = $legacyBase . DIRECTORY_SEPARATOR . "{$userId}.{$ext}";
if (file_exists($p)) {
return $p;
}
}
// 3) Try any file under legacy dir that contains the user id in name
if (is_dir($legacyBase)) {
$files = glob($legacyBase . DIRECTORY_SEPARATOR . "*{$userId}*.*");
if (!empty($files)) {
return $files[0];
}
}
// 4) Fallback: try legacy database connection (connection name 'legacy')
// If a legacy DB connection is configured, query `users.icon` for avatar filename.
try {
$conn = $legacyConnection ?: (config('database.connections.legacy') ? 'legacy' : null);
if ($conn) {
$icon = DB::connection($conn)->table('users')->where('id', $userId)->value('icon');
if (!empty($icon)) {
// If icon looks like an absolute path, use it directly; otherwise resolve under legacy base path
$p = $icon;
if (!file_exists($p)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . ltrim($icon, '\/');
}
if (file_exists($p)) {
if ($this->output->isVerbose()) {
$this->line("[legacy-db] user={$userId} icon={$icon} resolved={$p}");
}
return $p;
}
if ($this->output->isVerbose()) {
$this->line("[legacy-db] user={$userId} icon={$icon} not found at resolved path {$p}");
}
}
}
} catch (\Throwable $e) {
// Non-fatal: just skip legacy DB if query fails or connection missing
}
return null;
}
/**
* GD-based encode to WebP binary blob.
*
* @param string $path
* @param int $quality
* @return string
*/
protected function gdEncodeWebp(string $path, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
ob_start();
imagewebp($src, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
return $data;
}
/**
* Create a center-cropped square thumbnail and return WebP binary.
*
* @param string $path
* @param int $size
* @param int $quality
* @return string
*/
protected function gdCreateThumbnailWebp(string $path, int $size, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
$w = imagesx($src);
$h = imagesy($src);
$min = min($w, $h);
$srcX = (int) floor(($w - $min) / 2);
$srcY = (int) floor(($h - $min) / 2);
$dst = imagecreatetruecolor($size, $size);
// preserve transparency
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $size, $size, $min, $min);
ob_start();
imagewebp($dst, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
imagedestroy($dst);
return $data;
}
/**
* Create GD image resource from file path.
*
* @param string $path
* @return resource|false
*/
protected function gdCreateResource(string $path)
{
$info = @getimagesize($path);
if (!$info) {
return false;
}
$mime = $info['mime'] ?? '';
switch ($mime) {
case 'image/jpeg':
return imagecreatefromjpeg($path);
case 'image/png':
return imagecreatefrompng($path);
case 'image/webp':
if (function_exists('imagecreatefromwebp')) {
return imagecreatefromwebp($path);
}
return false;
case 'image/gif':
if (function_exists('imagecreatefromgif')) {
$res = imagecreatefromgif($path);
if (!$res) {
return false;
}
// Ensure returned resource is truecolor (WebP requires truecolor)
if (!imageistruecolor($res)) {
$w = imagesx($res);
$h = imagesy($res);
$true = imagecreatetruecolor($w, $h);
// Preserve transparency where possible
imagealphablending($true, false);
imagesavealpha($true, true);
// Fill with fully transparent color
$transparent = imagecolorallocatealpha($true, 0, 0, 0, 127);
imagefilledrectangle($true, 0, 0, $w, $h, $transparent);
// If the source has an indexed transparent color, try to preserve it
$transIndex = imagecolortransparent($res);
if ($transIndex >= 0) {
try {
$colorTotal = imagecolorstotal($res);
if ($transIndex >= 0 && $transIndex < $colorTotal) {
$colors = imagecolorsforindex($res, $transIndex);
if (is_array($colors)) {
$alphaColor = imagecolorallocatealpha($true, $colors['red'], $colors['green'], $colors['blue'], 127);
imagefilledrectangle($true, 0, 0, $w, $h, $alphaColor);
}
}
} catch (\Throwable $e) {
// Non-fatal: skip preserving indexed transparent color
}
}
// Copy pixels
imagecopy($true, $res, 0, 0, 0, 0, $w, $h);
imagedestroy($res);
return $true;
}
return $res;
}
return false;
default:
return false;
}
}
}

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

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

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

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

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

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Klevze\ControlPanel\Facades\FileManager;
use Klevze\ControlPanel\Core\Utils\Translation as TranslationUtil;
class ExportMissingTranslations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'translations:export-missing {file=admin} {--out=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Export missing translations for a file (e.g. admin) into a CSV';
private $translationURL = "https://cPad.dev/api/translation/get/list";
private $token = 'Ddt06xvjYX1TK792H4jAtld8UhgVORYIpkB7nBX6';
public function handle(): int
{
$type = $this->argument('file') ?? 'admin';
$this->info('Exporting missing translations for: ' . $type);
// Gather files to scan
$files = [];
$files = array_merge(
FileManager::getFileList(app_path(), true),
FileManager::getFileList(base_path('packages'), true),
FileManager::getFileList(resource_path(), true)
);
$tempTranslations = [];
foreach ($files as $file) {
$res = TranslationUtil::findTranslations($file, $type);
if (!empty($res) && is_array($res)) {
$tempTranslations[] = $res;
}
}
$tempTranslations = collect($tempTranslations)->collapse();
$missing = [];
foreach ($tempTranslations as $keycode => $row) {
$exists = DB::table('translations')->where('keycode', $keycode)->where('file', $type)->exists();
if (! $exists) {
$missing[] = $keycode;
}
}
$this->info('Found ' . count($missing) . ' missing keys');
// Fetch suggested translations from external service for sl and en
$suggestions = [];
if (!empty($missing)) {
$payload = [
'keys' => $missing,
'languages' => ['sl', 'en'],
];
try {
$resp = Http::withToken($this->token)->post($this->translationURL, $payload);
if ($resp->successful()) {
$suggestions = $resp->json();
} else {
$this->warn('Translation suggestion service returned ' . $resp->status());
}
} catch (\Throwable $e) {
$this->warn('Failed to call suggestion service: ' . $e->getMessage());
}
}
// Build CSV
$out = $this->option('out') ?: storage_path('app/translations_missing_' . $type . '.csv');
$fh = fopen($out, 'w');
if (! $fh) {
$this->error('Failed to open output file: ' . $out);
return 1;
}
// Header
fputcsv($fh, ['file','keycode','suggested_sl','suggested_en','placeholder']);
foreach ($missing as $key) {
$s_sl = $suggestions[$key]['sl'] ?? '';
$s_en = $suggestions[$key]['en'] ?? '';
$placeholder = $type . '.' . $key;
fputcsv($fh, [$type, $key, $s_sl, $s_en, $placeholder]);
}
fclose($fh);
$this->info('CSV exported to: ' . $out);
return 0;
}
}

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

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

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

View 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');
}
}

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

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

View File

@@ -2,43 +2,79 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\User; use App\Support\UsernamePolicy;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class ImportLegacyUsers extends Command class ImportLegacyUsers extends Command
{ {
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users}'; protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--dry-run : Preview which users would be skipped/deleted without making changes}';
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec'; protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
protected array $usedUsernames = []; protected string $migrationLogPath;
protected array $usedEmails = []; /** @var array<int,true> Legacy user IDs that qualify for import */
protected array $activeUserIds = [];
public function handle(): int public function handle(): int
{ {
$this->usedUsernames = User::pluck('username', 'username')->filter()->all(); $this->migrationLogPath = storage_path('logs/username_migration.log');
$this->usedEmails = User::pluck('email', 'email')->filter()->all(); @file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
$chunk = (int) $this->option('chunk'); // Build the set of legacy user IDs that have any meaningful activity.
$imported = 0; // Users outside this set will be skipped (or deleted from the new DB if already imported).
$skipped = 0; $this->activeUserIds = $this->buildActiveUserIds();
$this->info('Active legacy users (uploads / comments / forum): ' . count($this->activeUserIds));
if (! DB::getPdo()) { $chunk = (int) $this->option('chunk');
$dryRun = (bool) $this->option('dry-run');
$imported = 0;
$skipped = 0;
$purged = 0;
if (! DB::connection('legacy')->getPdo()) {
$this->error('Legacy DB connection "legacy" is not configured or reachable.'); $this->error('Legacy DB connection "legacy" is not configured or reachable.');
return self::FAILURE; return self::FAILURE;
} }
DB::table('users') DB::connection('legacy')->table('users')
->chunkById($chunk, function ($rows) use (&$imported, &$skipped) { ->chunkById($chunk, function ($rows) use (&$imported, &$skipped, &$purged, $dryRun) {
$ids = $rows->pluck('user_id')->all(); $ids = $rows->pluck('user_id')->all();
$stats = DB::table('users_statistics') $stats = DB::connection('legacy')->table('users_statistics')
->whereIn('user_id', $ids) ->whereIn('user_id', $ids)
->get() ->get()
->keyBy('user_id'); ->keyBy('user_id');
foreach ($rows as $row) { foreach ($rows as $row) {
$legacyId = (int) $row->user_id;
// ── Inactive user: no uploads, no comments, no forum activity ──
if (! isset($this->activeUserIds[$legacyId])) {
// If already imported into the new DB, purge it.
$existsInNew = DB::table('users')->where('id', $legacyId)->exists();
if ($existsInNew) {
if ($dryRun) {
$this->warn("[dry] Would DELETE inactive user_id={$legacyId} from new DB");
} else {
$this->purgeNewUser($legacyId);
$this->warn("[purge] Deleted inactive user_id={$legacyId} from new DB");
$purged++;
}
} else {
$this->line("[skip] user_id={$legacyId} no activity — skipping");
}
$skipped++;
continue;
}
if ($dryRun) {
$this->line("[dry] Would import user_id={$legacyId}");
$imported++;
continue;
}
try { try {
$this->importRow($row, $stats[$row->user_id] ?? null); $this->importRow($row, $stats[$row->user_id] ?? null);
$imported++; $imported++;
@@ -49,17 +85,70 @@ class ImportLegacyUsers extends Command
} }
}, 'user_id'); }, 'user_id');
$this->info("Imported: {$imported}, Skipped: {$skipped}"); $this->info("Imported: {$imported}, Skipped: {$skipped}, Purged: {$purged}");
return self::SUCCESS; return self::SUCCESS;
} }
/**
* Build a lookup array of legacy user IDs that qualify for import:
* uploaded at least one artwork (users_statistics.uploads > 0)
* posted at least one artwork comment (artworks_comments.user_id)
* created or posted to a forum thread (forum_topics / forum_posts)
*
* @return array<int,true>
*/
protected function buildActiveUserIds(): array
{
$rows = DB::connection('legacy')->select("
SELECT DISTINCT user_id FROM users_statistics WHERE uploads > 0
UNION
SELECT DISTINCT user_id FROM artworks_comments WHERE user_id > 0
UNION
SELECT DISTINCT user_id FROM forum_posts WHERE user_id > 0
UNION
SELECT DISTINCT user_id FROM forum_topics WHERE user_id > 0
");
$map = [];
foreach ($rows as $r) {
$map[(int) $r->user_id] = true;
}
return $map;
}
/**
* Remove all new-DB records for a given legacy user ID.
* Covers: users, user_profiles, user_statistics, username_redirects.
*/
protected function purgeNewUser(int $userId): void
{
DB::transaction(function () use ($userId) {
DB::table('username_redirects')->where('user_id', $userId)->delete();
DB::table('user_statistics')->where('user_id', $userId)->delete();
DB::table('user_profiles')->where('user_id', $userId)->delete();
DB::table('users')->where('id', $userId)->delete();
});
}
protected function importRow($row, $statRow = null): void protected function importRow($row, $statRow = null): void
{ {
$legacyId = (int) $row->user_id; $legacyId = (int) $row->user_id;
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
$username = $this->uniqueUsername($baseUsername);
$email = $this->prepareEmail($row->email ?? null, $username); // Use legacy username as-is (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; $legacyPassword = $row->password2 ?: $row->password ?: null;
@@ -84,56 +173,78 @@ class ImportLegacyUsers extends Command
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) { DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
$now = now(); $now = now();
$alreadyExists = DB::table('users')->where('id', $legacyId)->exists();
DB::table('users')->insert([ // All fields synced from legacy on every run
'id' => $legacyId, $sharedFields = [
'username' => $username, 'username' => $username,
'name' => $row->real_name ?: $username, 'username_changed_at' => $now,
'email' => $email, 'name' => $row->real_name ?: $username,
'password' => $passwordHash, 'email' => $email,
'is_active' => (int) ($row->active ?? 1) === 1, 'is_active' => (int) ($row->active ?? 1) === 1,
'needs_password_reset' => true, 'needs_password_reset' => true,
'role' => 'user', 'role' => 'user',
'legacy_password_algo' => null, 'legacy_password_algo' => null,
'last_visit_at' => $row->LastVisit ?: null, 'last_visit_at' => $row->LastVisit ?: null,
'created_at' => $row->joinDate ?: $now, 'updated_at' => $now,
'updated_at' => $now, ];
]);
DB::table('user_profiles')->insert([ if ($alreadyExists) {
'user_id' => $legacyId, // Sync all fields from legacy — password is never overwritten on re-runs
'bio' => $row->about_me ?: $row->description ?: null, // (unless --force-reset-all was passed, in which case the caller handles it
'avatar' => $row->picture ?: null, // separately outside this transaction).
'cover_image' => $row->cover_art ?: null, DB::table('users')->where('id', $legacyId)->update($sharedFields);
'country' => $row->country ?: null, } else {
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null, DB::table('users')->insert(array_merge($sharedFields, [
'language' => $row->lang ?: null, 'id' => $legacyId,
'birthdate' => $row->birth ?: null, 'password' => $passwordHash,
'gender' => $row->gender ?: 'X', 'created_at' => $row->joinDate ?: $now,
'website' => $row->web ?: null, ]));
'created_at' => $now,
'updated_at' => $now,
]);
if (!empty($row->web)) {
DB::table('user_social_links')->insert([
'user_id' => $legacyId,
'platform' => 'website',
'url' => $row->web,
'created_at' => $now,
'updated_at' => $now,
]);
} }
DB::table('user_statistics')->insert([ DB::table('user_profiles')->updateOrInsert(
'user_id' => $legacyId, ['user_id' => $legacyId],
'uploads' => $uploads, [
'downloads' => $downloads, 'about' => $row->about_me ?: $row->description ?: null,
'pageviews' => $pageviews, 'avatar_legacy' => $row->picture ?: null,
'awards' => $awards, 'cover_image' => $row->cover_art ?: null,
'created_at' => $now, 'country' => $row->country ?: null,
'updated_at' => $now, 'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
]); 'language' => $row->lang ?: null,
'birthdate' => $row->birth ?: null,
'gender' => $row->gender ?: 'X',
'website' => $row->web ?: null,
'updated_at' => $now,
]
);
// Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`.
DB::table('user_statistics')->updateOrInsert(
['user_id' => $legacyId],
[
'uploads_count' => $uploads,
'downloads_received_count' => $downloads,
'artwork_views_received_count' => $pageviews,
'awards_received_count' => $awards,
'updated_at' => $now,
]
);
if (Schema::hasTable('username_redirects')) {
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
if ($old !== '' && $old !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
[
'new_username' => $username,
'user_id' => $legacyId,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
}); });
} }
@@ -151,45 +262,7 @@ class ImportLegacyUsers extends Command
protected function sanitizeUsername(string $username): string protected function sanitizeUsername(string $username): string
{ {
$username = strtolower(trim($username)); return UsernamePolicy::sanitizeLegacy($username);
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
return trim($username, '.-') ?: 'user';
}
protected function uniqueUsername(string $base): string
{
$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;
} }
protected function sanitizeEmailLocal(string $value): string protected function sanitizeEmailLocal(string $value): string

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

View 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}");
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\AchievementService;
use App\Services\XPService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RecalculateUserXpCommand extends Command
{
protected $signature = 'skinbase:recalculate-user-xp
{user_id? : The ID of a single user to recompute}
{--all : Recompute XP and level for all non-deleted users}
{--chunk=1000 : Chunk size when --all is used}
{--dry-run : Show computed values without writing}
{--sync-achievements : Re-run achievement checks after a live recalculation}';
protected $description = 'Rebuild stored user XP, level, and rank from user_xp_logs';
public function handle(XPService $xp, AchievementService $achievements): int
{
$userId = $this->argument('user_id');
$all = (bool) $this->option('all');
$dryRun = (bool) $this->option('dry-run');
$syncAchievements = (bool) $this->option('sync-achievements');
$chunk = max(1, (int) $this->option('chunk'));
if ($userId !== null && $all) {
$this->error('Provide either a user_id or --all, not both.');
return self::FAILURE;
}
if ($userId !== null) {
return $this->recalculateSingle((int) $userId, $xp, $achievements, $dryRun, $syncAchievements);
}
if ($all) {
return $this->recalculateAll($xp, $achievements, $chunk, $dryRun, $syncAchievements);
}
$this->error('Provide a user_id or use --all.');
return self::FAILURE;
}
private function recalculateSingle(
int $userId,
XPService $xp,
AchievementService $achievements,
bool $dryRun,
bool $syncAchievements,
): int {
$exists = DB::table('users')->where('id', $userId)->exists();
if (! $exists) {
$this->error("User {$userId} not found.");
return self::FAILURE;
}
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
$this->line("{$label} Recomputing XP for user #{$userId}...");
$result = $xp->recalculateStoredProgress($userId, ! $dryRun);
$this->table(
['Field', 'Stored', 'Computed'],
[
['xp', $result['previous']['xp'], $result['computed']['xp']],
['level', $result['previous']['level'], $result['computed']['level']],
['rank', $result['previous']['rank'], $result['computed']['rank']],
]
);
if ($dryRun) {
if ($syncAchievements) {
$pending = $achievements->previewUnlocks($userId);
$this->line('Achievements preview: ' . (empty($pending) ? 'no pending unlocks' : implode(', ', $pending)));
}
$this->warn('Dry-run: no changes written.');
return self::SUCCESS;
}
if ($syncAchievements) {
$unlocked = $achievements->checkAchievements($userId);
$this->line('Achievements checked: ' . (empty($unlocked) ? 'no new unlocks' : implode(', ', $unlocked)));
}
$this->info($result['changed'] ? "XP updated for user #{$userId}." : "User #{$userId} was already in sync.");
return self::SUCCESS;
}
private function recalculateAll(
XPService $xp,
AchievementService $achievements,
int $chunk,
bool $dryRun,
bool $syncAchievements,
): int {
$total = DB::table('users')->whereNull('deleted_at')->count();
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
$this->info("{$label} Recomputing XP for {$total} users (chunk={$chunk})...");
$processed = 0;
$changed = 0;
$pendingAchievementUsers = 0;
$pendingAchievementUnlocks = 0;
$appliedAchievementUnlocks = 0;
$bar = $this->output->createProgressBar($total);
$bar->start();
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use ($xp, $achievements, $dryRun, $syncAchievements, &$processed, &$changed, &$pendingAchievementUsers, &$pendingAchievementUnlocks, &$appliedAchievementUnlocks, $bar): void {
foreach ($users as $user) {
$result = $xp->recalculateStoredProgress((int) $user->id, ! $dryRun);
if ($result['changed']) {
$changed++;
}
if ($syncAchievements) {
if ($dryRun) {
$pending = $achievements->previewUnlocks((int) $user->id);
if (! empty($pending)) {
$pendingAchievementUsers++;
$pendingAchievementUnlocks += count($pending);
}
} else {
$unlocked = $achievements->checkAchievements((int) $user->id);
$appliedAchievementUnlocks += count($unlocked);
}
}
$processed++;
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$summary = "Done - {$processed} users processed, {$changed} " . ($dryRun ? 'would change.' : 'updated.');
if ($syncAchievements) {
if ($dryRun) {
$summary .= " Achievement preview: {$pendingAchievementUnlocks} pending unlock(s) across {$pendingAchievementUsers} user(s).";
} else {
$summary .= " Achievements re-checked: {$appliedAchievementUnlocks} unlock(s) applied.";
}
}
$this->info($summary);
return self::SUCCESS;
}
}

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

View File

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

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

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

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tag;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class SeedTagInteractionDemoCommand extends Command
{
protected $signature = 'analytics:seed-tag-interaction-demo
{--days=14 : Number of days to generate demo events for}
{--per-day=90 : Approximate number of demo events to write per day}
{--refresh : Remove existing seeded demo events first}
{--force : Allow running outside local/testing environments}';
protected $description = 'Generate demo tag interaction events for local analytics dashboards and ranking validation';
public function handle(): int
{
if (! app()->environment(['local', 'testing']) && ! $this->option('force')) {
$this->error('This command is restricted to local/testing unless --force is provided.');
return self::FAILURE;
}
$days = max(1, min(60, (int) $this->option('days')));
$perDay = max(10, min(500, (int) $this->option('per-day')));
$tags = Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->limit(20)
->get(['id', 'name', 'slug', 'usage_count']);
if ($tags->count() < 2) {
$this->error('At least two active tags are required to generate demo interaction data.');
return self::FAILURE;
}
$transitions = $this->buildTransitionMap($tags);
if ($this->option('refresh')) {
DB::table('tag_interaction_events')
->where('meta->seeded_demo', true)
->delete();
}
$now = now();
for ($offset = $days - 1; $offset >= 0; $offset--) {
$date = Carbon::today()->subDays($offset);
$rows = [];
for ($index = 0; $index < $perDay; $index++) {
$surface = $this->pickSurface();
$sourceTag = $tags->random();
$targetTag = $this->pickTargetTag($surface, $sourceTag->slug, $transitions, $tags);
$query = in_array($surface, ['search_suggestion', 'rescue_suggestion', 'recent_search'], true)
? $this->queryForTag($targetTag)
: null;
$rows[] = [
'event_date' => $date->toDateString(),
'event_type' => 'click',
'surface' => $surface,
'user_id' => null,
'session_key' => hash('sha256', 'demo-' . $date->toDateString() . '-' . $index . '-' . $surface),
'tag_slug' => $targetTag->slug,
'source_tag_slug' => in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)
? $sourceTag->slug
: null,
'query' => $query,
'position' => random_int(1, 4),
'meta' => json_encode([
'seeded_demo' => true,
'seeded_at' => $now->toISOString(),
], JSON_THROW_ON_ERROR),
'occurred_at' => $date->copy()->setTime(random_int(8, 23), random_int(0, 59), random_int(0, 59)),
'created_at' => $now,
'updated_at' => $now,
];
}
foreach (array_chunk($rows, 250) as $chunk) {
DB::table('tag_interaction_events')->insert($chunk);
}
$this->call('analytics:aggregate-tag-interactions', ['--date' => $date->toDateString()]);
}
$this->info("Seeded demo tag interaction events for the last {$days} days.");
return self::SUCCESS;
}
private function buildTransitionMap(Collection $tags): array
{
$pairs = DB::table('artwork_tag as source_pivot')
->join('tags as source_tag', 'source_tag.id', '=', 'source_pivot.tag_id')
->join('artwork_tag as target_pivot', 'target_pivot.artwork_id', '=', 'source_pivot.artwork_id')
->join('tags as target_tag', 'target_tag.id', '=', 'target_pivot.tag_id')
->whereIn('source_tag.id', $tags->pluck('id')->all())
->whereIn('target_tag.id', $tags->pluck('id')->all())
->whereColumn('source_tag.id', '!=', 'target_tag.id')
->groupBy('source_tag.slug', 'target_tag.slug')
->orderByRaw('COUNT(*) DESC')
->get([
'source_tag.slug as source_slug',
'target_tag.slug as target_slug',
DB::raw('COUNT(*) as pair_count'),
]);
$map = [];
foreach ($pairs as $pair) {
$map[$pair->source_slug][] = $pair->target_slug;
}
return $map;
}
private function pickSurface(): string
{
$roll = random_int(1, 100);
return match (true) {
$roll <= 32 => 'search_suggestion',
$roll <= 46 => 'rescue_suggestion',
$roll <= 58 => 'recent_search',
$roll <= 80 => 'related_chip',
$roll <= 94 => 'related_cluster',
default => 'top_companion',
};
}
private function pickTargetTag(string $surface, string $sourceSlug, array $transitions, Collection $tags): object
{
if (in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)) {
$candidateSlugs = $transitions[$sourceSlug] ?? [];
if ($candidateSlugs !== []) {
$slug = $candidateSlugs[array_rand($candidateSlugs)];
return $tags->firstWhere('slug', $slug) ?? $tags->where('slug', '!=', $sourceSlug)->random();
}
return $tags->where('slug', '!=', $sourceSlug)->random();
}
return $tags->random();
}
private function queryForTag(object $tag): string
{
$name = trim((string) ($tag->name ?? $tag->slug));
$options = array_values(array_filter([
strtolower($name),
strtolower((string) ($tag->slug ?? '')),
strtolower(substr($name, 0, max(3, min(strlen($name), 7)))),
]));
return $options[array_rand($options)];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Countries\CountrySyncService;
use Illuminate\Console\Command;
use Throwable;
final class SyncCountriesCommand extends Command
{
protected $signature = 'skinbase:sync-countries
{--deactivate-missing : Mark countries missing from the source as inactive}
{--no-fallback : Fail instead of using the local fallback dataset when remote fetch fails}';
protected $description = 'Synchronize ISO 3166 country metadata into the local countries table.';
public function __construct(
private readonly CountrySyncService $countrySyncService,
) {
parent::__construct();
}
public function handle(): int
{
try {
$summary = $this->countrySyncService->sync(
allowFallback: ! (bool) $this->option('no-fallback'),
deactivateMissing: (bool) $this->option('deactivate-missing') ? true : null,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info('Countries synchronized successfully.');
$this->line('Source: '.($summary['source'] ?? 'unknown'));
$this->line('Fetched: '.(int) ($summary['total_fetched'] ?? 0));
$this->line('Inserted: '.(int) ($summary['inserted'] ?? 0));
$this->line('Updated: '.(int) ($summary['updated'] ?? 0));
$this->line('Skipped: '.(int) ($summary['skipped'] ?? 0));
$this->line('Invalid: '.(int) ($summary['invalid'] ?? 0));
$this->line('Deactivated: '.(int) ($summary['deactivated'] ?? 0));
$this->line('Backfilled users: '.(int) ($summary['backfilled_users'] ?? 0));
return self::SUCCESS;
}
}

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

View File

@@ -6,6 +6,24 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use App\Console\Commands\ImportLegacyUsers; use App\Console\Commands\ImportLegacyUsers;
use App\Console\Commands\ImportCategories; use App\Console\Commands\ImportCategories;
use App\Console\Commands\MigrateFeaturedWorks; use App\Console\Commands\MigrateFeaturedWorks;
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
use App\Console\Commands\SeedTagInteractionDemoCommand;
use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\SyncCountriesCommand;
use App\Console\Commands\CompareFeedAbCommand;
use App\Console\Commands\RecalculateTrendingCommand;
use App\Console\Commands\RecalculateRankingsCommand;
use App\Console\Commands\MetricsSnapshotHourlyCommand;
use App\Console\Commands\RecalculateHeatCommand;
use App\Jobs\UpdateLeaderboardsJob;
use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand;
use App\Console\Commands\PublishScheduledArtworksCommand;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
{ {
@@ -16,9 +34,28 @@ class Kernel extends ConsoleKernel
*/ */
protected $commands = [ protected $commands = [
ImportLegacyUsers::class, ImportLegacyUsers::class,
\App\Console\Commands\EnforceUsernamePolicy::class,
ImportCategories::class, ImportCategories::class,
MigrateFeaturedWorks::class, MigrateFeaturedWorks::class,
\App\Console\Commands\AvatarsMigrate::class,
\App\Console\Commands\AvatarsBulkUpdate::class,
\App\Console\Commands\ResetAllUserPasswords::class, \App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class,
PublishScheduledArtworksCommand::class,
BackfillArtworkEmbeddingsCommand::class,
AggregateSimilarArtworkAnalyticsCommand::class,
AggregateFeedAnalyticsCommand::class,
AggregateTagInteractionAnalyticsCommand::class,
SeedTagInteractionDemoCommand::class,
EvaluateFeedWeightsCommand::class,
CompareFeedAbCommand::class,
AiTagArtworksCommand::class,
SyncCountriesCommand::class,
\App\Console\Commands\MigrateFollows::class,
RecalculateTrendingCommand::class,
RecalculateRankingsCommand::class,
MetricsSnapshotHourlyCommand::class,
RecalculateHeatCommand::class,
]; ];
/** /**
@@ -26,7 +63,62 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{ {
// $schedule->command('inspire')->hourly(); $schedule->command('uploads:cleanup')->dailyAt('03:00');
// Publish artworks whose scheduled publish_at has passed
$schedule->command('artworks:publish-scheduled')
->everyMinute()
->name('publish-scheduled-artworks')
->withoutOverlapping(2) // prevent overlap up to 2 minutes
->runInBackground();
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
$schedule->command('analytics:aggregate-tag-interactions')->dailyAt('03:30');
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
// ── Ranking system (rank_v1) ────────────────────────────────────────
// Step 1: compute per-artwork scores every hour at :05
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
// Step 2: build ranked lists every hour at :15 (after scores are ready)
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground();
// ── Ranking Engine V2 — runs every 30 min ──────────────────────────
$schedule->command('nova:recalculate-rankings --sync-rank-scores')
->everyThirtyMinutes()
->name('ranking-v2')
->withoutOverlapping()
->runInBackground();
$schedule->job(new UpdateLeaderboardsJob)
->hourlyAt(20)
->name('leaderboards-refresh')
->withoutOverlapping()
->runInBackground();
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
// Step 1: snapshot metric totals every hour at :00
$schedule->command('nova:metrics-snapshot-hourly')
->hourly()
->name('metrics-snapshot-hourly')
->withoutOverlapping()
->runInBackground();
// Step 2: recalculate heat scores every 15 minutes
$schedule->command('nova:recalculate-heat')
->everyFifteenMinutes()
->name('recalculate-heat')
->withoutOverlapping()
->runInBackground();
// Step 3: prune old snapshots daily at 04:00
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
->dailyAt('04:00');
$schedule->command('skinbase:sync-countries')
->monthlyOn(1, '03:40')
->name('sync-countries')
->withoutOverlapping()
->runInBackground();
} }
/** /**

View 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
) {
}
}

View 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
) {
}
}

View 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
) {
}
}

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

View 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
) {
}
}

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

View 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
) {
}
}

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

View 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 (01)
* @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'] ?? []),
);
}
}

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

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Events\Achievements;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class AchievementCheckRequested
{
use Dispatchable, SerializesModels;
public function __construct(public readonly int $userId) {}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Events\Achievements;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserXpUpdated
{
use Dispatchable, SerializesModels;
public function __construct(public readonly int $userId) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View 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,
) {}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Country;
use App\Services\Countries\CountrySyncService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Throwable;
final class CountryAdminController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->query('q', ''));
$countries = Country::query()
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($countryQuery) use ($search): void {
$countryQuery
->where('iso2', 'like', '%'.$search.'%')
->orWhere('iso3', 'like', '%'.$search.'%')
->orWhere('name_common', 'like', '%'.$search.'%')
->orWhere('name_official', 'like', '%'.$search.'%');
});
})
->ordered()
->paginate(50)
->withQueryString();
return view('admin.countries.index', [
'countries' => $countries,
'search' => $search,
]);
}
public function sync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
{
try {
$summary = $countrySyncService->sync();
} catch (Throwable $exception) {
return redirect()
->route('admin.countries.index')
->with('error', $exception->getMessage());
}
$message = sprintf(
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
(string) ($summary['source'] ?? 'unknown'),
(int) ($summary['inserted'] ?? 0),
(int) ($summary['updated'] ?? 0),
(int) ($summary['skipped'] ?? 0),
(int) ($summary['deactivated'] ?? 0),
);
return redirect()
->route('admin.countries.index')
->with('success', $message);
}
public function cpMain(Request $request): View
{
$view = $this->index($request);
return view('admin.countries.cpad', $view->getData());
}
public function cpSync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
{
try {
$summary = $countrySyncService->sync();
} catch (Throwable $exception) {
return redirect()
->route('admin.cp.countries.main')
->with('msg_error', $exception->getMessage());
}
$message = sprintf(
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
(string) ($summary['source'] ?? 'unknown'),
(int) ($summary['inserted'] ?? 0),
(int) ($summary['updated'] ?? 0),
(int) ($summary['skipped'] ?? 0),
(int) ($summary['deactivated'] ?? 0),
);
return redirect()
->route('admin.cp.countries.main')
->with('msg_success', $message);
}
}

View 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),
]);
}
}

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\User;
use App\Notifications\StoryStatusNotification;
use App\Services\StoryPublicationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class StoryAdminController extends Controller
{
public function index(): View
{
$stories = Story::query()
->with(['creator'])
->latest('created_at')
->paginate(25);
return view('admin.stories.index', ['stories' => $stories]);
}
public function review(): View
{
$stories = Story::query()
->with(['creator'])
->where('status', 'pending_review')
->orderByDesc('submitted_for_review_at')
->paginate(25);
return view('admin.stories.review', ['stories' => $stories]);
}
public function create(): View
{
return view('admin.stories.create', [
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story = Story::query()->create([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'slug' => $this->uniqueSlug($validated['title']),
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? now() : null,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? now() : null,
]);
if (! empty($validated['tags'])) {
$story->tags()->sync($validated['tags']);
}
if ($validated['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return redirect()->route('admin.stories.edit', ['story' => $story->id])
->with('status', 'Story created.');
}
public function edit(Story $story): View
{
$story->load('tags');
return view('admin.stories.edit', [
'story' => $story,
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function update(Request $request, Story $story): RedirectResponse
{
$wasPublished = $story->published_at !== null || $story->status === 'published';
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story->update([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? ($story->published_at ?? now()) : $story->published_at,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
]);
$story->tags()->sync($validated['tags'] ?? []);
if (! $wasPublished && $validated['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return back()->with('status', 'Story updated.');
}
public function destroy(Story $story): RedirectResponse
{
$story->delete();
return redirect()->route('admin.stories.index')->with('status', 'Story deleted.');
}
public function publish(Story $story): RedirectResponse
{
app(StoryPublicationService::class)->publish($story, 'published', [
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
]);
return back()->with('status', 'Story published.');
}
public function show(Story $story): View
{
return view('admin.stories.show', [
'story' => $story->load(['creator', 'tags']),
]);
}
public function approve(Request $request, Story $story): RedirectResponse
{
app(StoryPublicationService::class)->publish($story, 'approved', [
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => null,
]);
return back()->with('status', 'Story approved and published.');
}
public function reject(Request $request, Story $story): RedirectResponse
{
$validated = $request->validate([
'reason' => ['required', 'string', 'max:1000'],
]);
$story->update([
'status' => 'rejected',
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => $validated['reason'],
]);
$story->creator?->notify(new StoryStatusNotification($story, 'rejected', $validated['reason']));
return back()->with('status', 'Story rejected and creator notified.');
}
public function moderateComments(): View
{
return view('admin.stories.comments-moderation');
}
private function uniqueSlug(string $title): string
{
$base = Str::slug($title);
$slug = $base;
$n = 2;
while (Story::query()->where('slug', $slug)->exists()) {
$slug = $base . '-' . $n;
$n++;
}
return $slug;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\TagInteractionReportService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
final class TagInteractionReportController extends Controller
{
public function __construct(private readonly TagInteractionReportService $reportService) {}
public function index(Request $request): View
{
$validated = $request->validate([
'from' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'date_format:Y-m-d'],
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 15);
abort_if($from > $to, 422, 'Invalid date range.');
$report = $this->reportService->buildReport($from, $to, $limit);
return view('admin.reports.tags', [
'filters' => [
'from' => $from,
'to' => $to,
'limit' => $limit,
],
'overview' => $report['overview'],
'dailyClicks' => $report['daily_clicks'],
'bySurface' => $report['by_surface'],
'topTags' => $report['top_tags'],
'topQueries' => $report['top_queries'],
'topTransitions' => $report['top_transitions'],
'latestAggregatedDate' => $report['latest_aggregated_date'],
]);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\TagInteractionReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class TagInteractionReportController extends Controller
{
public function __construct(private readonly TagInteractionReportService $reportService) {}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'from' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'date_format:Y-m-d'],
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 15);
if ($from > $to) {
return response()->json([
'message' => 'Invalid date range: from must be before or equal to to.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$report = $this->reportService->buildReport($from, $to, $limit);
return response()->json([
'meta' => [
'from' => $from,
'to' => $to,
'limit' => $limit,
'generated_at' => now()->toISOString(),
'latest_aggregated_date' => $report['latest_aggregated_date'],
],
'overview' => $report['overview'],
'daily_clicks' => $report['daily_clicks'],
'by_surface' => $report['by_surface'],
'top_tags' => $report['top_tags'],
'top_queries' => $report['top_queries'],
'top_transitions' => $report['top_transitions'],
], Response::HTTP_OK);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
final class UsernameApprovalController extends Controller
{
public function pending(): JsonResponse
{
$rows = DB::table('username_approval_requests')
->where('status', 'pending')
->orderBy('created_at')
->get([
'id',
'user_id',
'requested_username',
'context',
'similar_to',
'payload',
'created_at',
]);
return response()->json(['data' => $rows], Response::HTTP_OK);
}
public function approve(int $id, Request $request): JsonResponse
{
$row = DB::table('username_approval_requests')->where('id', $id)->first();
if (! $row) {
return response()->json(['message' => 'Request not found.'], Response::HTTP_NOT_FOUND);
}
if ((string) $row->status !== 'pending') {
return response()->json(['message' => 'Request is not pending.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
DB::beginTransaction();
try {
DB::table('username_approval_requests')
->where('id', $id)
->update([
'status' => 'approved',
'reviewed_by' => (int) $request->user()->id,
'reviewed_at' => now(),
'review_note' => (string) $request->input('note', ''),
'updated_at' => now(),
]);
if ((string) $row->context === 'profile_update' && ! empty($row->user_id)) {
$this->applyProfileRename((int) $row->user_id, (string) $row->requested_username);
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'success' => true,
'id' => $id,
'status' => 'approved',
], Response::HTTP_OK);
}
public function reject(int $id, Request $request): JsonResponse
{
$affected = DB::table('username_approval_requests')
->where('id', $id)
->where('status', 'pending')
->update([
'status' => 'rejected',
'reviewed_by' => (int) $request->user()->id,
'reviewed_at' => now(),
'review_note' => (string) $request->input('note', ''),
'updated_at' => now(),
]);
if ($affected === 0) {
return response()->json(['message' => 'Request not found or not pending.'], Response::HTTP_NOT_FOUND);
}
return response()->json([
'success' => true,
'id' => $id,
'status' => 'rejected',
], Response::HTTP_OK);
}
private function applyProfileRename(int $userId, string $requestedUsername): void
{
$user = User::query()->find($userId);
if (! $user) {
return;
}
$requested = UsernamePolicy::normalize($requestedUsername);
if ($requested === '') {
throw new \RuntimeException('Requested username is invalid.');
}
$exists = User::query()
->whereRaw('LOWER(username) = ?', [$requested])
->where('id', '!=', $userId)
->exists();
if ($exists) {
throw new \RuntimeException('Requested username is already taken.');
}
$old = UsernamePolicy::normalize((string) ($user->username ?? ''));
if ($old === $requested) {
return;
}
$user->username = $requested;
$user->username_changed_at = now();
if (Schema::hasColumn('users', 'last_username_change_at')) {
$user->last_username_change_at = now();
}
$user->save();
if ($old !== '') {
DB::table('username_history')->insert([
'user_id' => $userId,
'old_username' => $old,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
[
'new_username' => $requested,
'user_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
]
);
}
}
}

View 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,
];
}
}

View File

@@ -0,0 +1,270 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use App\Models\UserMention;
use App\Notifications\ArtworkCommentedNotification;
use App\Notifications\ArtworkMentionedNotification;
use App\Services\ContentSanitizer;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
/**
* Artwork comment CRUD.
*
* POST /api/artworks/{artworkId}/comments store
* PUT /api/artworks/{artworkId}/comments/{id} update (own comment)
* DELETE /api/artworks/{artworkId}/comments/{id} delete (own or admin)
* GET /api/artworks/{artworkId}/comments list (paginated)
*/
class ArtworkCommentController extends Controller
{
private const MAX_LENGTH = 10_000;
// ─────────────────────────────────────────────────────────────────────────
// List
// ─────────────────────────────────────────────────────────────────────────
public function index(Request $request, int $artworkId): JsonResponse
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
$comments = ArtworkComment::with([
'user', 'user.profile',
'approvedReplies',
])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->whereNull('parent_id')
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$userId = $request->user()?->id;
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true));
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Store
// ─────────────────────────────────────────────────────────────────────────
public function store(Request $request, int $artworkId): JsonResponse
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
]);
$raw = $request->input('content');
$parentId = $request->input('parent_id');
// If replying, validate parent belongs to same artwork and is approved
if ($parentId) {
$parent = ArtworkComment::where('artwork_id', $artwork->id)
->where('is_approved', true)
->find($parentId);
if (! $parent) {
return response()->json([
'errors' => ['parent_id' => ['The comment you are replying to is no longer available.']],
], 422);
}
}
// Validate markdown-lite content
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
}
$rendered = ContentSanitizer::render($raw);
$comment = ArtworkComment::create([
'artwork_id' => $artwork->id,
'user_id' => $request->user()->id,
'parent_id' => $parentId,
'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw,
'rendered_content' => $rendered,
'is_approved' => true, // auto-approve; extend with moderation as needed
]);
// Bust the comments cache for this user's 'all' feed
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
// Record activity event (fire-and-forget; never break the response)
try {
\App\Models\ActivityEvent::record(
actorId: $request->user()->id,
type: \App\Models\ActivityEvent::TYPE_COMMENT,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
);
} catch (\Throwable) {}
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
}
// ─────────────────────────────────────────────────────────────────────────
// Update
// ─────────────────────────────────────────────────────────────────────────
public function update(Request $request, int $artworkId, int $commentId): JsonResponse
{
$comment = ArtworkComment::where('artwork_id', $artworkId)
->findOrFail($commentId);
Gate::authorize('update', $comment);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
]);
$raw = $request->input('content');
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
}
$rendered = ContentSanitizer::render($raw);
$comment->update([
'content' => $raw,
'raw_content' => $raw,
'rendered_content' => $rendered,
]);
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)]);
}
// ─────────────────────────────────────────────────────────────────────────
// Delete
// ─────────────────────────────────────────────────────────────────────────
public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse
{
$comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId);
Gate::authorize('delete', $comment);
$comment->delete();
Cache::forget('comments.latest.all.page1');
return response()->json(['message' => 'Comment deleted.'], 200);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
{
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
$data = [
'id' => $c->id,
'parent_id' => $c->parent_id,
'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $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),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
];
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
} elseif ($includeReplies && $c->relationLoaded('replies')) {
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
} else {
$data['replies'] = [];
}
return $data;
}
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
{
$notifiedUserIds = [];
$creatorId = (int) ($artwork->user_id ?? 0);
if ($creatorId > 0 && $creatorId !== (int) $actor->id) {
$creator = User::query()->find($creatorId);
if ($creator) {
$creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
$notifiedUserIds[] = (int) $creator->id;
}
}
if ($parentId) {
$parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0);
if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) {
$parentUser = User::query()->find($parentUserId);
if ($parentUser) {
$parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
$notifiedUserIds[] = (int) $parentUser->id;
}
}
}
User::query()
->whereIn(
'id',
UserMention::query()
->where('comment_id', (int) $comment->id)
->pluck('mentioned_user_id')
->map(fn ($id) => (int) $id)
->unique()
->all()
)
->get()
->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void {
if ((int) $mentionedUser->id === (int) $actor->id) {
return;
}
$mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor));
});
}
}

View File

@@ -2,11 +2,14 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Artworks\ArtworkCreateRequest;
use App\Http\Resources\ArtworkListResource; use App\Http\Resources\ArtworkListResource;
use App\Http\Resources\ArtworkResource; use App\Http\Resources\ArtworkResource;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\Artworks\ArtworkDraftService;
use App\Models\Category; use App\Models\Category;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ArtworkController extends Controller class ArtworkController extends Controller
{ {
@@ -17,6 +20,32 @@ class ArtworkController extends Controller
$this->service = $service; $this->service = $service;
} }
/**
* POST /api/artworks
* Creates a draft artwork placeholder for the upload pipeline.
*/
public function store(ArtworkCreateRequest $request, ArtworkDraftService $drafts)
{
$user = $request->user();
$data = $request->validated();
$categoryId = isset($data['category']) && ctype_digit((string) $data['category'])
? (int) $data['category']
: null;
$result = $drafts->createDraft(
(int) $user->id,
(string) $data['title'],
isset($data['description']) ? (string) $data['description'] : null,
$categoryId
);
return response()->json([
'artwork_id' => $result->artworkId,
'status' => $result->status,
], Response::HTTP_CREATED);
}
/** /**
* GET /api/v1/artworks/{slug} * GET /api/v1/artworks/{slug}
* Returns a single public artwork resource by slug. * Returns a single public artwork resource by slug.

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* POST /api/art/{id}/download
*
* Records a download event and returns the full-resolution download URL.
*
* Responsibilities:
* 1. Validates the artwork is public and published.
* 2. Inserts a row in artwork_downloads (artwork_id, user_id, ip, user_agent).
* 3. Increments artwork_stats.downloads + forwards to creator stats.
* 4. Returns {"ok": true, "url": "<download_url>"} so the frontend can
* trigger the actual browser download.
*
* The frontend fires this POST on click, then uses the returned URL to
* trigger the file download (or falls back to the pre-resolved URL it
* already has).
*/
final class ArtworkDownloadController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->with(['user:id'])
->where('id', $id)
->first();
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
// Record the download event — non-blocking, errors are swallowed.
$this->recordDownload($request, $artwork);
// Increment counters — deferred via Redis when available.
try {
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
} catch (\Throwable) {
// Stats failure must never interrupt the download.
}
// Resolve the highest-resolution download URL available.
$url = $this->resolveDownloadUrl($artwork);
// Build a user-friendly download filename: "title-slug.file_ext"
$ext = $artwork->file_ext ?: $artwork->thumb_ext ?: 'webp';
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id;
$filename = $slug . '.' . $ext;
return response()->json([
'ok' => true,
'url' => $url,
'filename' => $filename,
'size' => (int) ($artwork->file_size ?? 0),
]);
}
/**
* Insert a row in artwork_downloads.
* Uses a raw insert for the binary(16) IP column.
* Silently ignores failures (analytics should never break user flow).
*/
private function recordDownload(Request $request, Artwork $artwork): void
{
try {
$ip = $request->ip() ?? '0.0.0.0';
$bin = @inet_pton($ip);
DB::table('artwork_downloads')->insert([
'artwork_id' => $artwork->id,
'user_id' => $request->user()?->id,
'ip' => $bin !== false ? $bin : null,
'ip_address' => mb_substr((string) $ip, 0, 45),
'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024),
'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535),
'created_at' => now(),
]);
} catch (\Throwable) {
// Analytics failure must never interrupt the download.
}
}
/**
* Resolve the original full-resolution CDN URL.
*
* Originals are stored at: {cdn}/original/{h1}/{h2}/{hash}.{file_ext}
* h1 = first 2 chars of hash, h2 = next 2 chars, filename = full hash + file_ext.
* Falls back to XL LG MD thumbnail when hash is unavailable.
*/
private function resolveDownloadUrl(Artwork $artwork): string
{
$hash = $artwork->hash ?? null;
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
if (!empty($hash)) {
$h = strtolower(preg_replace('/[^a-f0-9]/', '', $hash));
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
return sprintf('%s/original/%s/%s/%s.%s', $cdn, $h1, $h2, $h, $ext);
}
// Fallback: best available thumbnail size
foreach (['xl', 'lg', 'md'] as $size) {
$thumb = ThumbnailPresenter::present($artwork, $size);
if (!empty($thumb['url'])) {
return (string) $thumb['url'];
}
}
return '';
}
}

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Events\Achievements\AchievementCheckRequested;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Notifications\ArtworkLikedNotification;
use App\Services\FollowService;
use App\Services\UserStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class ArtworkInteractionController extends Controller
{
public function bookmark(Request $request, int $artworkId): JsonResponse
{
$this->toggleSimple(
request: $request,
table: 'artwork_bookmarks',
keyColumns: ['user_id', 'artwork_id'],
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
insertPayload: ['created_at' => now(), 'updated_at' => now()],
requiredTable: 'artwork_bookmarks'
);
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
public function favorite(Request $request, int $artworkId): JsonResponse
{
$state = $request->boolean('state', true);
$changed = $this->toggleSimple(
request: $request,
table: 'artwork_favourites',
keyColumns: ['user_id', 'artwork_id'],
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
insertPayload: ['created_at' => now(), 'updated_at' => now()],
requiredTable: 'artwork_favourites'
);
$this->syncArtworkStats($artworkId);
// Update creator's favorites_received_count
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if ($creatorId) {
$svc = app(UserStatsService::class);
if ($state && $changed) {
$svc->incrementFavoritesReceived($creatorId);
$svc->setLastActiveAt((int) $request->user()->id);
// Record activity event (new favourite only)
try {
\App\Models\ActivityEvent::record(
actorId: (int) $request->user()->id,
type: \App\Models\ActivityEvent::TYPE_FAVORITE,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artworkId,
);
} catch (\Throwable) {}
} elseif (! $state && $changed) {
$svc->decrementFavoritesReceived($creatorId);
}
}
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
public function like(Request $request, int $artworkId): JsonResponse
{
$changed = $this->toggleSimple(
request: $request,
table: 'artwork_likes',
keyColumns: ['user_id', 'artwork_id'],
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
insertPayload: ['created_at' => now(), 'updated_at' => now()],
requiredTable: 'artwork_likes'
);
$this->syncArtworkStats($artworkId);
if ($request->boolean('state', true) && $changed) {
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
$actorId = (int) $request->user()->id;
if ($creatorId > 0 && $creatorId !== $actorId) {
app(XPService::class)->awardArtworkLikeReceived($creatorId, $artworkId, $actorId);
$creator = \App\Models\User::query()->find($creatorId);
$artwork = Artwork::query()->find($artworkId);
if ($creator && $artwork) {
$creator->notify(new ArtworkLikedNotification($artwork, $request->user()));
}
event(new AchievementCheckRequested($creatorId));
}
}
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
public function report(Request $request, int $artworkId): JsonResponse
{
if (! Schema::hasTable('artwork_reports')) {
return response()->json(['message' => 'Reporting unavailable'], 422);
}
$data = $request->validate([
'reason' => ['nullable', 'string', 'max:1000'],
]);
DB::table('artwork_reports')->updateOrInsert(
[
'artwork_id' => $artworkId,
'reporter_user_id' => (int) $request->user()->id,
],
[
'reason' => trim((string) ($data['reason'] ?? '')) ?: null,
'reported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]
);
return response()->json(['ok' => true, 'reported' => true]);
}
public function follow(Request $request, int $userId): JsonResponse
{
$actorId = (int) $request->user()->id;
if ($actorId === $userId) {
return response()->json(['message' => 'Cannot follow yourself'], 422);
}
$svc = app(FollowService::class);
$state = $request->has('state')
? $request->boolean('state')
: ! $request->isMethod('delete');
if ($state) {
$svc->follow($actorId, $userId);
} else {
$svc->unfollow($actorId, $userId);
}
return response()->json([
'ok' => true,
'is_following' => $state,
'followers_count' => $svc->followersCount($userId),
]);
}
/**
* POST /api/artworks/{id}/share record a share event (Phase 2 tracking).
*/
public function share(Request $request, int $artworkId): JsonResponse
{
$data = $request->validate([
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed'],
]);
if (Schema::hasTable('artwork_shares')) {
DB::table('artwork_shares')->insert([
'artwork_id' => $artworkId,
'user_id' => $request->user()?->id,
'platform' => $data['platform'],
'created_at' => now(),
]);
}
return response()->json(['ok' => true]);
}
private function toggleSimple(
Request $request,
string $table,
array $keyColumns,
array $keyValues,
array $insertPayload,
string $requiredTable
): bool {
if (! Schema::hasTable($requiredTable)) {
abort(422, 'Interaction unavailable');
}
$state = $request->boolean('state', true);
$query = DB::table($table);
foreach ($keyColumns as $column) {
$query->where($column, $keyValues[$column]);
}
if ($state) {
if (! $query->exists()) {
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
return true;
}
} else {
return $query->delete() > 0;
}
return false;
}
private function syncArtworkStats(int $artworkId): void
{
if (! Schema::hasTable('artwork_stats')) {
return;
}
$favorites = Schema::hasTable('artwork_favourites')
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $artworkId],
[
'favorites' => $favorites,
'rating_count' => $likes,
]
);
}
private function statusPayload(int $viewerId, int $artworkId): array
{
$isBookmarked = Schema::hasTable('artwork_bookmarks')
? DB::table('artwork_bookmarks')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
$isFavorited = Schema::hasTable('artwork_favourites')
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
$isLiked = Schema::hasTable('artwork_likes')
? DB::table('artwork_likes')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
$favorites = Schema::hasTable('artwork_favourites')
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0;
$bookmarks = Schema::hasTable('artwork_bookmarks')
? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
return [
'ok' => true,
'is_bookmarked' => $isBookmarked,
'is_favorited' => $isFavorited,
'is_liked' => $isLiked,
'stats' => [
'bookmarks' => $bookmarks,
'favorites' => $favorites,
'likes' => $likes,
],
];
}
}

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

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

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* POST /api/art/{id}/view
*
* Fire-and-forget view tracker.
*
* Deduplication strategy (layered):
* 1. Session key (`art_viewed.{id}`) prevents double-counts within the
* same browser session (survives page reloads).
* 2. Route throttle (5 per 10 minutes per IP+artwork) catches bots that
* don't send session cookies.
*
* The frontend should additionally guard with sessionStorage so it only
* calls this endpoint once per page load.
*/
final class ArtworkViewController extends Controller
{
public function __construct(
private readonly ArtworkStatsService $stats,
private readonly XPService $xp,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->where('id', $id)
->first();
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
$sessionKey = 'art_viewed.' . $id;
// Already counted this session — return early without touching the DB.
if ($request->hasSession() && $request->session()->has($sessionKey)) {
return response()->json(['ok' => true, 'counted' => false]);
}
// Write persistent event log (auth user_id or null for guests).
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
// Defer to Redis when available, fall back to direct DB increment.
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
$viewerId = $request->user()?->id;
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
$this->xp->awardArtworkViewReceived(
(int) $artwork->user_id,
(int) $artwork->id,
$viewerId,
(string) $request->ip(),
);
}
// Mark this session so the artwork is not counted again.
if ($request->hasSession()) {
$request->session()->put($sessionKey, true);
}
return response()->json(['ok' => true, 'counted' => true]);
}
}

View File

@@ -23,9 +23,14 @@ class BrowseController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$perPage = min(max((int) $request->get('per_page', 24), 1), 100); $perPage = $this->resolvePerPage($request);
$sort = (string) $request->get('sort', 'latest');
$paginator = $this->service->browsePublicArtworks($perPage); $paginator = $this->service->browsePublicArtworks($perPage, $sort);
$paginator->appends([
'limit' => $perPage,
'sort' => $sort,
]);
return ArtworkListResource::collection($paginator); return ArtworkListResource::collection($paginator);
} }
@@ -36,14 +41,20 @@ class BrowseController extends Controller
*/ */
public function byContentType(Request $request, string $contentTypeSlug) public function byContentType(Request $request, string $contentTypeSlug)
{ {
$perPage = min(max((int) $request->get('per_page', 24), 1), 100); $perPage = $this->resolvePerPage($request);
$sort = (string) $request->get('sort', 'latest');
try { try {
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage); $paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage, $sort);
} catch (ModelNotFoundException $e) { } catch (ModelNotFoundException $e) {
abort(404); abort(404);
} }
$paginator->appends([
'limit' => $perPage,
'sort' => $sort,
]);
if ($paginator->count() === 0) { if ($paginator->count() === 0) {
return response()->json(['message' => 'Gone'], 410); return response()->json(['message' => 'Gone'], 410);
} }
@@ -57,22 +68,38 @@ class BrowseController extends Controller
*/ */
public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath) public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath)
{ {
$perPage = min(max((int) $request->get('per_page', 24), 1), 100); $perPage = $this->resolvePerPage($request);
$sort = (string) $request->get('sort', 'latest');
$slugs = array_merge([ $slugs = array_merge([
strtolower($contentTypeSlug), strtolower($contentTypeSlug),
], array_values(array_filter(explode('/', trim($categoryPath, '/'))))); ], array_values(array_filter(explode('/', trim($categoryPath, '/')))));
try { try {
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage); $paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage, $sort);
} catch (ModelNotFoundException $e) { } catch (ModelNotFoundException $e) {
abort(404); abort(404);
} }
$paginator->appends([
'limit' => $perPage,
'sort' => $sort,
]);
if ($paginator->count() === 0) { if ($paginator->count() === 0) {
return response()->json(['message' => 'Gone'], 410); return response()->json(['message' => 'Gone'], 410);
} }
return ArtworkListResource::collection($paginator); return ArtworkListResource::collection($paginator);
} }
private function resolvePerPage(Request $request): int
{
$limit = (int) $request->query('limit', 0);
$perPage = (int) $request->query('per_page', 0);
$value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 24);
return min(max($value, 1), 100);
}
} }

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\CommunityActivityService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class CommunityActivityController extends Controller
{
public function __construct(private readonly CommunityActivityService $activityService)
{
}
public function index(Request $request): JsonResponse
{
$filter = $this->resolveFilter($request);
if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
$feed = $this->activityService->getFeed(
viewer: $request->user(),
filter: $filter,
page: (int) $request->query('page', 1),
perPage: (int) $request->query('per_page', CommunityActivityService::DEFAULT_PER_PAGE),
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
);
return response()->json($feed);
}
private function resolveFilter(Request $request): string
{
if ($request->filled('type') && ! $request->filled('filter')) {
return (string) $request->query('type', 'all');
}
if ($request->boolean('following') && ! $request->filled('filter')) {
return 'following';
}
return (string) $request->query('filter', 'all');
}
}

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

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

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

View 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();
}
}

View 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(),
],
]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Leaderboard;
use App\Services\LeaderboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class LeaderboardController extends Controller
{
public function creators(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_CREATOR, (string) $request->query('period', 'weekly'))
);
}
public function artworks(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_ARTWORK, (string) $request->query('period', 'weekly'))
);
}
public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
);
}
}

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

View 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,
]);
}
}

View 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.');
}
}

View 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(),
]);
}
}

View 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']);
}
}

Some files were not shown because too many files have changed in this diff Show More