Compare commits

..

29 Commits

Author SHA1 Message Date
cab4fbd83e optimizations 2026-03-28 19:15:39 +01:00
0b25d9570a added flags icons 2026-03-28 19:15:21 +01:00
73260e7eae updated gitignore and .env.example 2026-03-28 09:20:02 +01:00
2608be7420 Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz 2026-03-22 09:13:39 +01:00
e8b5edf5d2 feat: add Reverb realtime messaging 2026-03-21 12:51:59 +01:00
60f78e8235 Add Laravel broadcasting setup
Register channel routes and add the default broadcasting configuration generated for Laravel broadcasting support.
2026-03-21 11:08:18 +01:00
979e011257 Refactor dashboard and upload flows
Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
2026-03-21 11:02:22 +01:00
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
11116 changed files with 1108875 additions and 856639 deletions

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

@@ -41,9 +41,39 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
QUEUE_CONNECTION=redis
MESSAGING_REALTIME=true
MESSAGING_BROADCAST_QUEUE=broadcasts
MESSAGING_TYPING_TTL=8
MESSAGING_TYPING_CACHE_STORE=redis
MESSAGING_PRESENCE_TTL=90
MESSAGING_CONVERSATION_PRESENCE_TTL=45
MESSAGING_PRESENCE_CACHE_STORE=redis
MESSAGING_RECOVERY_MAX_MESSAGES=100
MESSAGING_OFFLINE_FALLBACK_ONLY=true
HORIZON_NAME=skinbase-nova
HORIZON_PATH=horizon
HORIZON_PREFIX=skinbase_nova_horizon:
REVERB_APP_ID=skinbase-local
REVERB_APP_KEY=skinbase-local-key
REVERB_APP_SECRET=skinbase-local-secret
REVERB_HOST=127.0.0.1
REVERB_PORT=8080
REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
REVERB_SERVER_PATH=
REVERB_SCALING_ENABLED=false
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
SKINBASE_UPLOADS_V2=false
@@ -57,6 +87,21 @@ SKINBASE_DUPLICATE_HASH_POLICY=block
VISION_ENABLED=true
VISION_QUEUE=default
VISION_IMAGE_VARIANT=md
VISION_GATEWAY_URL=
VISION_GATEWAY_TIMEOUT=10
VISION_GATEWAY_CONNECT_TIMEOUT=3
VISION_VECTOR_GATEWAY_ENABLED=true
VISION_VECTOR_GATEWAY_URL=
VISION_VECTOR_GATEWAY_API_KEY=
VISION_VECTOR_GATEWAY_COLLECTION=images
VISION_VECTOR_GATEWAY_TIMEOUT=20
VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT=5
VISION_VECTOR_GATEWAY_RETRIES=1
VISION_VECTOR_GATEWAY_RETRY_DELAY_MS=250
VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT=/vectors/upsert
VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT=/vectors/search
VISION_VECTOR_GATEWAY_DELETE_ENDPOINT=/vectors/delete
VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT=/vectors/collections
# CLIP service (set base URL to enable CLIP calls)
CLIP_BASE_URL=
@@ -81,6 +126,8 @@ RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
RECOMMENDATIONS_MIN_DIM=64
RECOMMENDATIONS_MAX_DIM=4096
RECOMMENDATIONS_BACKFILL_BATCH=200
SIMILARITY_VECTOR_ENABLED=false
SIMILARITY_VECTOR_ADAPTER=pgvector
# Personalized discovery foundation (Phase 8)
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
@@ -94,6 +141,16 @@ DISCOVERY_WEIGHT_CLICK=2
DISCOVERY_WEIGHT_FAVORITE=4
DISCOVERY_WEIGHT_DOWNLOAD=3
DISCOVERY_CACHE_TTL_MINUTES=60
DISCOVERY_V3_ENABLED=false
DISCOVERY_V3_CACHE_VERSION=cache-v3
DISCOVERY_V3_CACHE_TTL_MINUTES=5
DISCOVERY_V3_VECTOR_SIMILARITY_WEIGHT=0.8
DISCOVERY_V3_VECTOR_BASE_SCORE=0.75
DISCOVERY_V3_MAX_SEED_ARTWORKS=3
DISCOVERY_V3_VECTOR_CANDIDATE_POOL=60
DISCOVERY_V3_SECTION_SIMILAR_STYLE_LIMIT=3
DISCOVERY_V3_SECTION_YOU_MAY_ALSO_LIKE_LIMIT=6
DISCOVERY_V3_SECTION_VISUALLY_RELATED_LIMIT=6
DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
DISCOVERY_RANKING_W1=0.65
DISCOVERY_RANKING_W2=0.20
@@ -145,6 +202,9 @@ YOLO_PHOTOGRAPHY_ONLY=true
# VISION_ENABLED=true
# VISION_QUEUE=vision
# VISION_IMAGE_VARIANT=md
# VISION_GATEWAY_URL=https://vision.internal
# VISION_GATEWAY_TIMEOUT=8
# VISION_GATEWAY_CONNECT_TIMEOUT=2
#
# CLIP_BASE_URL=https://clip.internal
# CLIP_ANALYZE_ENDPOINT=/analyze
@@ -174,6 +234,16 @@ YOLO_PHOTOGRAPHY_ONLY=true
# DISCOVERY_WEIGHT_CLICK=2
# DISCOVERY_WEIGHT_FAVORITE=4
# DISCOVERY_WEIGHT_DOWNLOAD=3
# DISCOVERY_V3_ENABLED=true
# DISCOVERY_V3_CACHE_VERSION=cache-v3
# DISCOVERY_V3_CACHE_TTL_MINUTES=5
# DISCOVERY_V3_VECTOR_SIMILARITY_WEIGHT=0.8
# DISCOVERY_V3_VECTOR_BASE_SCORE=0.75
# DISCOVERY_V3_MAX_SEED_ARTWORKS=3
# DISCOVERY_V3_VECTOR_CANDIDATE_POOL=60
# DISCOVERY_V3_SECTION_SIMILAR_STYLE_LIMIT=3
# DISCOVERY_V3_SECTION_YOU_MAY_ALSO_LIKE_LIMIT=6
# DISCOVERY_V3_SECTION_VISUALLY_RELATED_LIMIT=6
# DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
# DISCOVERY_RANKING_W1=0.65
# DISCOVERY_RANKING_W2=0.20
@@ -232,3 +302,49 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# ─── Early-Stage Growth System ───────────────────────────────────────────────
# Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to normal behaviour.
# NOVA_EARLY_GROWTH_MODE: off | light | aggressive
NOVA_EARLY_GROWTH_ENABLED=false
NOVA_EARLY_GROWTH_MODE=off
# Module toggles (only active when NOVA_EARLY_GROWTH_ENABLED=true)
NOVA_EGS_ADAPTIVE_WINDOW=true
NOVA_EGS_GRID_FILLER=true
NOVA_EGS_SPOTLIGHT=true
NOVA_EGS_ACTIVITY_LAYER=false
# AdaptiveTimeWindow thresholds
NOVA_EGS_UPLOADS_PER_DAY_NARROW=10
NOVA_EGS_UPLOADS_PER_DAY_WIDE=3
NOVA_EGS_WINDOW_NARROW_DAYS=7
NOVA_EGS_WINDOW_MEDIUM_DAYS=30
NOVA_EGS_WINDOW_WIDE_DAYS=90
# GridFiller minimum items per page
NOVA_EGS_GRID_MIN_RESULTS=12
# Auto-disable when site reaches organic scale
NOVA_EGS_AUTO_DISABLE=false
NOVA_EGS_AUTO_DISABLE_UPLOADS=50
NOVA_EGS_AUTO_DISABLE_USERS=500
# Cache TTLs (seconds)
NOVA_EGS_SPOTLIGHT_TTL=3600
NOVA_EGS_BLEND_TTL=300
NOVA_EGS_WINDOW_TTL=600
NOVA_EGS_ACTIVITY_TTL=1800
# ─── OAuth / Social Login ─────────────────────────────────────────────────────
# Google — https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=/auth/google/callback
# Discord — https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=/auth/discord/callback
# Apple — https://developer.apple.com/account/resources/identifiers/list/serviceId
# Apple sign in removed

28
.gitignore vendored
View File

@@ -19,9 +19,37 @@
/public/files
/storage/*.key
/storage/pail
/storage/app
/storage/framework/cache
/storage/framework/sessions
/storage/framework/views
/storage/logs
/storage/testing
/storage/*.log
/storage/*.key
/storage/*.sqlite
/storage/*.sqlite3
/storage/*.zip
/storage/*.tar.gz
/storage/*.tar.gz
/storage/*.tar.bz2
/storage/*.tar.xz
/storage/*.tar
/storage/*.tgz
/storage/*.tbz2
/storage/*.txz
/storage/*.zip
/storage/*.tar.gz
/storage/*.tar.bz2
/storage/*.tar.xz
/storage/*.tar
/storage/*.tgz
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
/oldSite/*
oldSite
packages
/packages/*
/public/admin/*

View File

@@ -421,6 +421,21 @@ curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \
- AI tags appear on the artwork when services are healthy.
- Failures are logged, but publish is unaffected.
## Queue workers
The contact form mails are queued. To process them you need a worker. Locally you can run a foreground worker:
```
php artisan queue:work --sleep=3 --tries=3
```
For production we provide example configs under `deploy/`:
- `deploy/supervisor/skinbase-queue.conf` — Supervisor config
- `deploy/systemd/skinbase-queue.service` — systemd unit file
See `docs/QUEUE.md` for full setup steps and commands.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

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

@@ -81,7 +81,8 @@ class Chat
echo '<div class="row well">';
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 '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
echo '</div>';

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class AggregateDiscoveryFeedbackCommand extends Command
{
protected $signature = 'analytics:aggregate-discovery-feedback {--date= : Date (Y-m-d), defaults to yesterday}';
protected $description = 'Aggregate discovery feedback events into daily metrics by algorithm and surface';
public function handle(): int
{
if (! Schema::hasTable('user_discovery_events') || ! Schema::hasTable('discovery_feedback_daily_metrics')) {
$this->warn('Required discovery feedback tables are missing.');
return self::SUCCESS;
}
$date = $this->option('date')
? (string) $this->option('date')
: now()->subDay()->toDateString();
$surfaceExpression = $this->surfaceExpression();
$rows = DB::table('user_discovery_events')
->selectRaw('algo_version')
->selectRaw($surfaceExpression . ' AS surface')
->selectRaw("SUM(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS views")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->selectRaw("SUM(CASE WHEN event_type = 'favorite' THEN 1 ELSE 0 END) AS favorites")
->selectRaw("SUM(CASE WHEN event_type = 'download' THEN 1 ELSE 0 END) AS downloads")
->selectRaw("SUM(CASE WHEN event_type = 'hide_artwork' THEN 1 ELSE 0 END) AS hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'dislike_tag' THEN 1 ELSE 0 END) AS disliked_tags")
->selectRaw("SUM(CASE WHEN event_type = 'unhide_artwork' THEN 1 ELSE 0 END) AS undo_hidden_artworks")
->selectRaw("SUM(CASE WHEN event_type = 'undo_dislike_tag' THEN 1 ELSE 0 END) AS undo_disliked_tags")
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
->selectRaw('COUNT(DISTINCT artwork_id) AS unique_artworks')
->whereDate('event_date', $date)
->groupBy('algo_version', DB::raw($surfaceExpression))
->get();
foreach ($rows as $row) {
$views = (int) ($row->views ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$favorites = (int) ($row->favorites ?? 0);
$downloads = (int) ($row->downloads ?? 0);
$feedbackActions = $favorites + $downloads;
$hiddenArtworks = (int) ($row->hidden_artworks ?? 0);
$dislikedTags = (int) ($row->disliked_tags ?? 0);
$undoHiddenArtworks = (int) ($row->undo_hidden_artworks ?? 0);
$undoDislikedTags = (int) ($row->undo_disliked_tags ?? 0);
$negativeFeedbackActions = $hiddenArtworks + $dislikedTags;
$undoActions = $undoHiddenArtworks + $undoDislikedTags;
DB::table('discovery_feedback_daily_metrics')->updateOrInsert(
[
'metric_date' => $date,
'algo_version' => (string) ($row->algo_version ?? ''),
'surface' => (string) ($row->surface ?? 'unknown'),
],
[
'views' => $views,
'clicks' => $clicks,
'favorites' => $favorites,
'downloads' => $downloads,
'hidden_artworks' => $hiddenArtworks,
'disliked_tags' => $dislikedTags,
'undo_hidden_artworks' => $undoHiddenArtworks,
'undo_disliked_tags' => $undoDislikedTags,
'feedback_actions' => $feedbackActions,
'negative_feedback_actions' => $negativeFeedbackActions,
'undo_actions' => $undoActions,
'unique_users' => (int) ($row->unique_users ?? 0),
'unique_artworks' => (int) ($row->unique_artworks ?? 0),
'ctr' => $views > 0 ? $clicks / $views : 0.0,
'favorite_rate_per_click' => $clicks > 0 ? $favorites / $clicks : 0.0,
'download_rate_per_click' => $clicks > 0 ? $downloads / $clicks : 0.0,
'feedback_rate_per_click' => $clicks > 0 ? $feedbackActions / $clicks : 0.0,
'updated_at' => now(),
'created_at' => now(),
]
);
}
$this->info("Aggregated discovery feedback for {$date}.");
return self::SUCCESS;
}
private function surfaceExpression(): string
{
if (DB::connection()->getDriverName() === 'sqlite') {
return "COALESCE(NULLIF(JSON_EXTRACT(meta, '$.gallery_type'), ''), NULLIF(JSON_EXTRACT(meta, '$.surface'), ''), 'unknown')";
}
return "COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.gallery_type')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.surface')), ''), 'unknown')";
}
}

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

@@ -47,21 +47,73 @@ final class AiTagArtworksCommand extends Command
// Prompt
// -------------------------------------------------------------------------
private const SYSTEM_PROMPT = <<<'PROMPT'
You are an expert at analysing visual artwork and generating concise, descriptive tags.
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 the artwork image and return a JSON array of relevant tags.
Cover: art style, subject/theme, dominant colours, mood, technique, and medium where visible.
private const USER_PROMPT = <<<'PROMPT'
Analyse this artwork image and return a JSON array of relevant tags.
Rules:
- Return ONLY a valid JSON array of lowercase strings no markdown, no explanation.
- Each tag must be 14 words, no punctuation except hyphens.
- Between 6 and 12 tags total.
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.
Example output:
["digital painting","fantasy","portrait","dark tones","glowing eyes","detailed","dramatic lighting"]
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;
// -------------------------------------------------------------------------

View File

@@ -0,0 +1,519 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Finder\Finder;
class AuditMigrationSchemaCommand extends Command
{
protected $signature = 'schema:audit-migrations
{--all-files : Audit all discovered migration files, not only migrations marked as ran}
{--json : Output the report as JSON}
{--base-path=* : Additional base paths to scan for migrations, relative to project root}';
protected $description = 'Compare the live database schema against executed migration files and report missing tables or columns';
private const NO_ARG_COLUMN_METHODS = [
'id' => ['id'],
'timestamps' => ['created_at', 'updated_at'],
'timestampsTz' => ['created_at', 'updated_at'],
'softDeletes' => ['deleted_at'],
'softDeletesTz' => ['deleted_at'],
'rememberToken' => ['remember_token'],
];
private const NON_COLUMN_METHODS = [
'index',
'unique',
'primary',
'foreign',
'foreignIdFor',
'dropColumn',
'dropColumns',
'dropIndex',
'dropUnique',
'dropPrimary',
'dropForeign',
'dropConstrainedForeignId',
'renameColumn',
'renameIndex',
'constrained',
'cascadeOnDelete',
'restrictOnDelete',
'nullOnDelete',
'cascadeOnUpdate',
'restrictOnUpdate',
'nullOnUpdate',
'after',
'nullable',
'default',
'useCurrent',
'useCurrentOnUpdate',
'comment',
'charset',
'collation',
'storedAs',
'virtualAs',
'generatedAs',
'always',
'invisible',
'first',
];
public function handle(): int
{
$migrationFiles = $this->discoverMigrationFiles();
$ranMigrations = collect(DB::table('migrations')->pluck('migration')->all())
->mapWithKeys(fn (string $migration): array => [$migration => true])
->all();
$expected = [];
$parsedFiles = 0;
foreach ($migrationFiles as $migrationName => $path) {
if (! $this->option('all-files') && ! isset($ranMigrations[$migrationName])) {
continue;
}
$parsedFiles++;
$operations = $this->parseMigrationFile($path);
foreach ($operations as $operation) {
$table = $operation['table'];
if ($operation['type'] === 'create-table' && isset($expected[$table])) {
$expected[$table]['sources'][$migrationName] = true;
if (Schema::hasTable($table)) {
$actualColumns = array_fill_keys(
array_map('strtolower', Schema::getColumnListing($table)),
true
);
$existingColumns = array_fill_keys(array_keys($expected[$table]['columns']), true);
$replacementColumns = [];
foreach ($operation['add'] as $column) {
if (! isset($existingColumns[$column]) && isset($actualColumns[$column])) {
$replacementColumns[$column] = true;
}
}
if ($replacementColumns !== []) {
foreach ($replacementColumns as $column => $_) {
$expected[$table]['columns'][$column] = true;
}
foreach (array_keys($expected[$table]['columns']) as $column) {
if (! isset($actualColumns[$column]) && ! isset($replacementColumns[$column])) {
unset($expected[$table]['columns'][$column]);
}
}
}
}
continue;
}
if ($operation['type'] === 'alter-table' && ! isset($expected[$table]) && ! Schema::hasTable($table)) {
continue;
}
$expected[$table] ??= [
'columns' => [],
'sources' => [],
];
$expected[$table]['sources'][$migrationName] = true;
if ($operation['type'] === 'drop-table') {
unset($expected[$table]);
continue;
}
foreach ($operation['add'] as $column) {
$expected[$table]['columns'][$column] = true;
}
foreach ($operation['drop'] as $column) {
unset($expected[$table]['columns'][$column]);
}
}
}
ksort($expected);
$report = [
'parsed_files' => $parsedFiles,
'expected_tables' => count($expected),
'missing_tables' => [],
'missing_columns' => [],
];
foreach ($expected as $table => $spec) {
$sources = array_keys($spec['sources']);
sort($sources);
if (! Schema::hasTable($table)) {
$report['missing_tables'][] = [
'table' => $table,
'sources' => $sources,
];
continue;
}
$actualColumns = array_map('strtolower', Schema::getColumnListing($table));
$expectedColumns = array_keys($spec['columns']);
sort($expectedColumns);
$missing = array_values(array_diff($expectedColumns, $actualColumns));
if ($missing !== []) {
$report['missing_columns'][] = [
'table' => $table,
'columns' => $missing,
'sources' => $sources,
];
}
}
if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->renderReport($report);
}
return ($report['missing_tables'] === [] && $report['missing_columns'] === [])
? self::SUCCESS
: self::FAILURE;
}
/**
* @return array<string, string>
*/
private function discoverMigrationFiles(): array
{
$paths = [
database_path('migrations'),
base_path('packages/klevze'),
];
foreach ((array) $this->option('base-path') as $relativePath) {
$resolved = base_path((string) $relativePath);
if (is_dir($resolved)) {
$paths[] = $resolved;
}
}
$finder = new Finder();
$finder->files()->name('*.php');
foreach ($paths as $path) {
if (is_dir($path)) {
$finder->in($path);
}
}
$files = [];
foreach ($finder as $file) {
$realPath = $file->getRealPath();
if (! $realPath) {
continue;
}
$normalized = str_replace('\\', '/', $realPath);
if (! str_contains($normalized, '/database/migrations/') && ! str_contains($normalized, '/Migrations/')) {
continue;
}
$files[pathinfo($realPath, PATHINFO_FILENAME)] = $realPath;
}
ksort($files);
return $files;
}
/**
* @return array<int, array{type:string, table:string, add:array<int,string>, drop:array<int,string>}>
*/
private function parseMigrationFile(string $path): array
{
$content = File::get($path);
$upBody = $this->extractMethodBody($content, 'up');
if ($upBody === null) {
return [];
}
$operations = [];
foreach ($this->extractSchemaClosures($upBody) as $closure) {
$operations[] = [
'type' => $closure['operation'],
'table' => $closure['table'],
'add' => $this->extractAddedColumns($closure['body']),
'drop' => $this->extractDroppedColumns($closure['body']),
];
}
if (preg_match_all("/Schema::drop(?:IfExists)?\(\s*['\"]([^'\"]+)['\"]\s*\)/", $upBody, $matches)) {
foreach ($matches[1] as $table) {
$operations[] = [
'type' => 'drop-table',
'table' => strtolower((string) $table),
'add' => [],
'drop' => [],
];
}
}
foreach ($this->extractRawAlterTableChanges($upBody) as $change) {
$operations[] = [
'type' => 'alter-table',
'table' => $change['table'],
'add' => [$change['new_column']],
'drop' => [$change['old_column']],
];
}
return $operations;
}
/**
* @return array<int, array{table:string, old_column:string, new_column:string}>
*/
private function extractRawAlterTableChanges(string $upBody): array
{
$changes = [];
if (preg_match_all(
'/ALTER\s+TABLE\s+[`"]?([^`"\s]+)[`"]?\s+CHANGE(?:\s+COLUMN)?\s+[`"]?([^`"\s]+)[`"]?\s+[`"]?([^`"\s]+)[`"]?/i',
$upBody,
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$oldColumn = strtolower((string) $match[2]);
$newColumn = strtolower((string) $match[3]);
if ($oldColumn === $newColumn) {
continue;
}
$changes[] = [
'table' => strtolower((string) $match[1]),
'old_column' => $oldColumn,
'new_column' => $newColumn,
];
}
}
return $changes;
}
private function extractMethodBody(string $content, string $method): ?string
{
if (! preg_match('/function\s+' . preg_quote($method, '/') . '\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/m', $content, $match, PREG_OFFSET_CAPTURE)) {
return null;
}
$start = $match[0][1] + strlen($match[0][0]) - 1;
$end = $this->findMatchingBrace($content, $start);
if ($end === null) {
return null;
}
return substr($content, $start + 1, $end - $start - 1);
}
private function findMatchingBrace(string $content, int $openingBracePos): ?int
{
$length = strlen($content);
$depth = 0;
$inSingle = false;
$inDouble = false;
for ($index = $openingBracePos; $index < $length; $index++) {
$char = $content[$index];
$prev = $index > 0 ? $content[$index - 1] : '';
if ($char === "'" && ! $inDouble && $prev !== '\\') {
$inSingle = ! $inSingle;
continue;
}
if ($char === '"' && ! $inSingle && $prev !== '\\') {
$inDouble = ! $inDouble;
continue;
}
if ($inSingle || $inDouble) {
continue;
}
if ($char === '{') {
$depth++;
continue;
}
if ($char === '}') {
$depth--;
if ($depth === 0) {
return $index;
}
}
}
return null;
}
/**
* @return array<int, array{operation:string, table:string, body:string}>
*/
private function extractSchemaClosures(string $upBody): array
{
preg_match_all('/Schema::(create|table)\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*function/s', $upBody, $matches, PREG_OFFSET_CAPTURE);
$closures = [];
foreach ($matches[0] as $index => $fullMatch) {
$offset = (int) $fullMatch[1];
$operation = strtolower((string) $matches[1][$index][0]) === 'create' ? 'create-table' : 'alter-table';
$table = strtolower((string) $matches[2][$index][0]);
$bracePos = strpos($upBody, '{', $offset);
if ($bracePos === false) {
continue;
}
$closing = $this->findMatchingBrace($upBody, $bracePos);
if ($closing === null) {
continue;
}
$closures[] = [
'operation' => $operation,
'table' => $table,
'body' => substr($upBody, $bracePos + 1, $closing - $bracePos - 1),
];
}
return $closures;
}
/**
* @return array<int, string>
*/
private function extractAddedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
$column = strtolower((string) $match[2]);
if (in_array($method, self::NON_COLUMN_METHODS, true)) {
continue;
}
$columns[$column] = true;
}
}
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*\)(?:[^;]*)?;/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
foreach (self::NO_ARG_COLUMN_METHODS[$method] ?? [] as $column) {
$columns[$column] = true;
}
}
}
if (preg_match_all('/\$table->(nullableMorphs|morphs|uuidMorphs|nullableUuidMorphs|ulidMorphs|nullableUlidMorphs)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$prefix = strtolower((string) $match[2]);
$columns[$prefix . '_type'] = true;
$columns[$prefix . '_id'] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @return array<int, string>
*/
private function extractDroppedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->dropColumn\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches)) {
foreach ($matches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
if (preg_match_all('/\$table->dropColumn\(\s*\[(.*?)\]\s*\);/s', $body, $matches)) {
foreach ($matches[1] as $arrayBody) {
if (preg_match_all('/[\'\"]([^\'\"]+)[\'\"]/', $arrayBody, $columnMatches)) {
foreach ($columnMatches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
}
}
if (preg_match_all('/\$table->renameColumn\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$columns[strtolower((string) $match[1])] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @param array{parsed_files:int, expected_tables:int, missing_tables:array<int,array{table:string,sources:array<int,string>}>, missing_columns:array<int,array{table:string,columns:array<int,string>,sources:array<int,string>}>} $report
*/
private function renderReport(array $report): void
{
$this->info(sprintf(
'Parsed %d migration file(s). Expected schema covers %d table(s).',
$report['parsed_files'],
$report['expected_tables']
));
if ($report['missing_tables'] === [] && $report['missing_columns'] === []) {
$this->info('Schema audit passed. No missing tables or columns detected.');
return;
}
if ($report['missing_tables'] !== []) {
$this->newLine();
$this->error('Missing tables:');
foreach ($report['missing_tables'] as $item) {
$this->line(sprintf(' - %s', $item['table']));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
if ($report['missing_columns'] !== []) {
$this->newLine();
$this->error('Missing columns:');
foreach ($report['missing_columns'] as $item) {
$this->line(sprintf(' - %s: %s', $item['table'], implode(', ', $item['columns'])));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
}
}

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

@@ -4,6 +4,7 @@ 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;
@@ -39,6 +40,7 @@ class AvatarsMigrate extends Command
protected $allowed = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
@@ -47,7 +49,7 @@ class AvatarsMigrate extends Command
*
* @var int[]
*/
protected $sizes = [32, 64, 128, 256, 512];
protected $sizes = [32, 40, 64, 80, 96, 128, 256, 512];
public function handle(): int
{
@@ -56,6 +58,7 @@ class AvatarsMigrate extends Command
$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}" : ''));
@@ -72,7 +75,7 @@ class AvatarsMigrate extends Command
$query->where('id', $userId);
}
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention, $verbose) {
foreach ($users as $user) {
/** @var UserProfile|null $profile */
$profile = $user->profile;
@@ -87,10 +90,13 @@ class AvatarsMigrate extends Command
continue;
}
$source = $this->findLegacyFile($profile, $user->id, $legacyPath);
$source = $this->findLegacyFile($profile, $user->id, $legacyPath, 'legacy');
//dd($source);
if (!$source) {
$this->line("[noop] user={$user->id} no legacy file found");
if ($verbose) {
$this->line("[noop] user={$user->id} no legacy file found");
}
continue;
}
@@ -123,14 +129,19 @@ class AvatarsMigrate extends Command
$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) {
$this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
$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 {
// Use hash-based directory structure: avatars/ab/cd/{hash}/
$hashPrefix1 = substr($hash, 0, 2);
$hashPrefix2 = substr($hash, 2, 2);
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
Storage::disk('public')->makeDirectory($dir);
// Save original.webp
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
@@ -155,7 +166,9 @@ class AvatarsMigrate extends Command
$profile->avatar_updated_at = Carbon::now();
$profile->save();
$this->line("[ok] user={$user->id} migrated hash={$hash}");
$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}");
@@ -185,8 +198,19 @@ class AvatarsMigrate extends Command
* @param string $legacyBase
* @return string|null
*/
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase): ?string
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;
@@ -212,6 +236,34 @@ class AvatarsMigrate extends Command
}
}
// 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;
}
@@ -308,6 +360,53 @@ class AvatarsMigrate extends Command
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,29 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\BackfillArtworkVectorIndexJob;
use Illuminate\Console\Command;
final class BackfillArtworkVectorIndexCommand extends Command
{
protected $signature = 'artworks:vectors-repair {--after-id=0 : Resume after this artwork id} {--batch=200 : Batch size for resumable fan-out} {--public-only : Repair only public, approved, published artworks} {--stale-hours=0 : Repair only artworks never indexed or older than this many hours}';
protected $description = 'Queue resumable vector gateway repair for artworks that already have local embeddings';
public function handle(): int
{
$afterId = max(0, (int) $this->option('after-id'));
$batch = max(1, min((int) $this->option('batch'), 1000));
$publicOnly = (bool) $this->option('public-only');
$staleHours = max(0, (int) $this->option('stale-hours'));
BackfillArtworkVectorIndexJob::dispatch($afterId, $batch, $publicOnly, $staleHours);
$this->info('Queued artwork vector repair (after_id=' . $afterId . ', batch=' . $batch . ', public_only=' . ($publicOnly ? 'yes' : 'no') . ', stale_hours=' . $staleHours . ').');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,571 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Models\UserActivity;
use App\Services\Activity\UserActivityService;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class BackfillUserActivitiesCommand extends Command
{
protected $signature = 'skinbase:backfill-user-activities
{--chunk=1000 : Number of source records to process per batch}
{--user-id= : Backfill only one actor user id}
{--types=all : Comma-separated groups: all, uploads, comments, likes, follows, achievements, forum}
{--dry-run : Preview inserts without writing changes}';
protected $description = 'Backfill historical profile activity into user_activities for existing users.';
public function __construct(private readonly UserActivityService $activities)
{
parent::__construct();
}
public function handle(): int
{
if (! Schema::hasTable('user_activities')) {
$this->error('The user_activities table does not exist. Run migrations first.');
return self::FAILURE;
}
$chunk = max(1, (int) $this->option('chunk'));
$userId = $this->option('user-id') !== null ? max(1, (int) $this->option('user-id')) : null;
$dryRun = (bool) $this->option('dry-run');
$groups = $this->parseGroups((string) $this->option('types'));
if ($groups === null) {
$this->error('Invalid --types value. Use one or more of: all, uploads, comments, likes, follows, achievements, forum.');
return self::FAILURE;
}
if ($userId !== null && ! User::query()->whereKey($userId)->exists()) {
$this->error("User id={$userId} was not found.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No activity rows will be inserted.');
}
$this->info('Backfilling historical profile activity.');
$summary = [];
foreach ($groups as $group) {
$groupSummary = match ($group) {
'uploads' => [
'uploads' => $this->backfillUploads($chunk, $userId, $dryRun),
],
'comments' => [
'comments' => $this->backfillArtworkComments($chunk, $userId, $dryRun),
],
'likes' => [
'likes' => $this->backfillArtworkLikes($chunk, $userId, $dryRun),
'favourites' => $this->backfillArtworkFavourites($chunk, $userId, $dryRun),
],
'follows' => [
'follows' => $this->backfillFollows($chunk, $userId, $dryRun),
],
'achievements' => [
'achievements' => $this->backfillAchievements($chunk, $userId, $dryRun),
],
'forum' => [
'forum_posts' => $this->backfillForumThreads($chunk, $userId, $dryRun),
'forum_replies' => $this->backfillForumReplies($chunk, $userId, $dryRun),
],
default => [],
};
$summary = [...$summary, ...$groupSummary];
}
foreach ($summary as $label => $stats) {
$this->line(sprintf(
'%s: processed=%d inserted=%d existing=%d skipped=%d',
$label,
(int) ($stats['processed'] ?? 0),
(int) ($stats['inserted'] ?? 0),
(int) ($stats['existing'] ?? 0),
(int) ($stats['skipped'] ?? 0),
));
}
$totalProcessed = array_sum(array_map(static fn (array $stats): int => (int) ($stats['processed'] ?? 0), $summary));
$totalInserted = array_sum(array_map(static fn (array $stats): int => (int) ($stats['inserted'] ?? 0), $summary));
$totalExisting = array_sum(array_map(static fn (array $stats): int => (int) ($stats['existing'] ?? 0), $summary));
$totalSkipped = array_sum(array_map(static fn (array $stats): int => (int) ($stats['skipped'] ?? 0), $summary));
$this->info(sprintf(
'Finished. processed=%d inserted=%d existing=%d skipped=%d',
$totalProcessed,
$totalInserted,
$totalExisting,
$totalSkipped,
));
return self::SUCCESS;
}
/**
* @return array<int, string>|null
*/
private function parseGroups(string $value): ?array
{
$items = collect(explode(',', strtolower(trim($value))))
->map(static fn (string $item): string => trim($item))
->filter()
->values();
if ($items->isEmpty() || $items->contains('all')) {
return ['uploads', 'comments', 'likes', 'follows', 'achievements', 'forum'];
}
$allowed = ['uploads', 'comments', 'likes', 'follows', 'achievements', 'forum'];
if ($items->contains(static fn (string $item): bool => ! in_array($item, $allowed, true))) {
return null;
}
return $items->unique()->values()->all();
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillUploads(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artworks')
->select(['id', 'user_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artworks.user_id'))
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->whereNull('deleted_at')
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'uploads',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_UPLOAD,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkComments(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_comments') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_comments')
->select(['id', 'user_id', 'parent_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_comments.user_id'))
->where('is_approved', true)
->whereNull('deleted_at')
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_comments.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'comments',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => $row->parent_id ? UserActivity::TYPE_REPLY : UserActivity::TYPE_COMMENT,
'entity_type' => UserActivity::ENTITY_ARTWORK_COMMENT,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkLikes(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_likes') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_likes')
->select(['id', 'user_id', 'artwork_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_likes.user_id'))
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_likes.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'likes',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_LIKE,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->artwork_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillArtworkFavourites(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('artwork_favourites') || ! Schema::hasTable('artworks')) {
return $this->emptyStats();
}
$query = DB::table('artwork_favourites')
->select(['id', 'user_id', 'artwork_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('artwork_favourites.user_id'))
->whereExists(function ($subquery): void {
$subquery->selectRaw('1')
->from('artworks')
->whereColumn('artworks.id', 'artwork_favourites.artwork_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->whereNull('artworks.deleted_at');
})
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'favourites',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FAVOURITE,
'entity_type' => UserActivity::ENTITY_ARTWORK,
'entity_id' => (int) $row->artwork_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillFollows(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('user_followers')) {
return $this->emptyStats();
}
$query = DB::table('user_followers')
->select(['id', 'follower_id', 'user_id', 'created_at'])
->where('follower_id', '>', 0)
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('user_followers.follower_id'))
->whereExists($this->existingUserSubquery('user_followers.user_id'))
->when($userId !== null, fn (Builder $builder) => $builder->where('follower_id', $userId));
return $this->backfillRows(
label: 'follows',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->follower_id,
'type' => UserActivity::TYPE_FOLLOW,
'entity_type' => UserActivity::ENTITY_USER,
'entity_id' => (int) $row->user_id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillAchievements(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('user_achievements')) {
return $this->emptyStats();
}
$query = DB::table('user_achievements')
->select(['id', 'user_id', 'achievement_id', 'unlocked_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('user_achievements.user_id'))
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'achievements',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_ACHIEVEMENT,
'entity_type' => UserActivity::ENTITY_ACHIEVEMENT,
'entity_id' => (int) $row->achievement_id,
'meta' => null,
'created_at' => $row->unlocked_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillForumThreads(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('forum_threads')) {
return $this->emptyStats();
}
$query = DB::table('forum_threads')
->select(['id', 'user_id', 'created_at'])
->where('user_id', '>', 0)
->whereExists($this->existingUserSubquery('forum_threads.user_id'))
->where('visibility', 'public')
->whereNull('deleted_at')
->when($userId !== null, fn (Builder $builder) => $builder->where('user_id', $userId));
return $this->backfillRows(
label: 'forum_posts',
query: $query,
chunk: $chunk,
chunkColumn: 'id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FORUM_POST,
'entity_type' => UserActivity::ENTITY_FORUM_THREAD,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
);
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillForumReplies(int $chunk, ?int $userId, bool $dryRun): array
{
if (! Schema::hasTable('forum_posts') || ! Schema::hasTable('forum_threads')) {
return $this->emptyStats();
}
$query = DB::table('forum_posts')
->select(['forum_posts.id', 'forum_posts.user_id', 'forum_posts.created_at'])
->join('forum_threads', 'forum_threads.id', '=', 'forum_posts.thread_id')
->where('forum_posts.user_id', '>', 0)
->whereExists($this->existingUserSubquery('forum_posts.user_id'))
->whereNull('forum_posts.deleted_at')
->where('forum_threads.visibility', 'public')
->whereNull('forum_threads.deleted_at')
->whereRaw('forum_posts.id <> (SELECT MIN(fp2.id) FROM forum_posts as fp2 WHERE fp2.thread_id = forum_posts.thread_id)')
->when(Schema::hasColumn('forum_posts', 'flagged'), fn (Builder $builder) => $builder->where('forum_posts.flagged', false))
->when($userId !== null, fn (Builder $builder) => $builder->where('forum_posts.user_id', $userId));
return $this->backfillRows(
label: 'forum_replies',
query: $query,
chunk: $chunk,
chunkColumn: 'forum_posts.id',
mapper: static fn (object $row): ?array => [
'user_id' => (int) $row->user_id,
'type' => UserActivity::TYPE_FORUM_REPLY,
'entity_type' => UserActivity::ENTITY_FORUM_POST,
'entity_id' => (int) $row->id,
'meta' => null,
'created_at' => $row->created_at,
],
dryRun: $dryRun,
chunkAlias: 'id',
);
}
/**
* @param callable(object): ?array{user_id:int,type:string,entity_type:string,entity_id:int,meta:?array,created_at:mixed} $mapper
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function backfillRows(
string $label,
Builder $query,
int $chunk,
string $chunkColumn,
callable $mapper,
bool $dryRun,
?string $chunkAlias = null,
): array {
$stats = $this->emptyStats();
$query->chunkById($chunk, function (Collection $rows) use (&$stats, $mapper, $dryRun): void {
$stats['processed'] += $rows->count();
$entries = $rows
->map($mapper)
->filter(static fn (?array $entry): bool => $entry !== null && (int) ($entry['user_id'] ?? 0) > 0 && (int) ($entry['entity_id'] ?? 0) > 0 && ! empty($entry['created_at']))
->values();
if ($entries->isEmpty()) {
$stats['skipped'] += $rows->count();
return;
}
$existing = $this->existingKeysForEntries($entries);
$pending = [];
foreach ($entries as $entry) {
$key = $this->entryKey($entry['user_id'], $entry['type'], $entry['entity_type'], $entry['entity_id']);
if (isset($existing[$key])) {
$stats['existing']++;
continue;
}
$pending[] = [
'user_id' => (int) $entry['user_id'],
'type' => (string) $entry['type'],
'entity_type' => (string) $entry['entity_type'],
'entity_id' => (int) $entry['entity_id'],
'meta' => $entry['meta'] !== null
? json_encode($entry['meta'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
: null,
'created_at' => $entry['created_at'],
];
}
if ($pending === []) {
return;
}
if ($dryRun) {
$stats['inserted'] += count($pending);
return;
}
DB::table('user_activities')->insert($pending);
$stats['inserted'] += count($pending);
collect($pending)
->pluck('user_id')
->unique()
->each(fn (int $userId): bool => tap(true, fn () => $this->activities->invalidateUserFeed($userId)));
}, $chunkColumn, $chunkAlias);
$this->line(sprintf('%s backfill complete.', $label));
return $stats;
}
/**
* @param Collection<int, array{user_id:int,type:string,entity_type:string,entity_id:int,meta:?array,created_at:mixed}> $entries
* @return array<string, true>
*/
private function existingKeysForEntries(Collection $entries): array
{
$existing = [];
$entries
->groupBy(fn (array $entry): string => $entry['type'] . '|' . $entry['entity_type'])
->each(function (Collection $groupedEntries, string $groupKey) use (&$existing): void {
[$type, $entityType] = explode('|', $groupKey, 2);
$userIds = $groupedEntries->pluck('user_id')->unique()->values()->all();
$entityIds = $groupedEntries->pluck('entity_id')->unique()->values()->all();
DB::table('user_activities')
->select(['user_id', 'entity_id'])
->where('type', $type)
->where('entity_type', $entityType)
->whereIn('user_id', $userIds)
->whereIn('entity_id', $entityIds)
->get()
->each(function (object $row) use (&$existing, $type, $entityType): void {
$existing[$this->entryKey((int) $row->user_id, $type, $entityType, (int) $row->entity_id)] = true;
});
});
return $existing;
}
private function entryKey(int $userId, string $type, string $entityType, int $entityId): string
{
return $userId . ':' . $type . ':' . $entityType . ':' . $entityId;
}
private function existingUserSubquery(string $column): \Closure
{
return static function ($subquery) use ($column): void {
$subquery->selectRaw('1')
->from('users')
->whereColumn('users.id', $column);
};
}
/**
* @return array{processed:int, inserted:int, existing:int, skipped:int}
*/
private function emptyStats(): array
{
return [
'processed' => 0,
'inserted' => 0,
'existing' => 0,
'skipped' => 0,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\CollectionBackgroundJobService;
use Illuminate\Console\Command;
class DispatchCollectionMaintenanceCommand extends Command
{
protected $signature = 'collections:dispatch-maintenance
{--health : Dispatch health and eligibility refresh jobs}
{--recommendations : Dispatch recommendation refresh jobs}
{--duplicates : Dispatch duplicate scan jobs}';
protected $description = 'Dispatch queued collection maintenance jobs for health, recommendation, and duplicate workflows.';
public function handle(CollectionBackgroundJobService $jobs): int
{
$runHealth = (bool) $this->option('health');
$runRecommendations = (bool) $this->option('recommendations');
$runDuplicates = (bool) $this->option('duplicates');
if (! $runHealth && ! $runRecommendations && ! $runDuplicates) {
$runHealth = true;
$runRecommendations = true;
$runDuplicates = true;
}
$summary = $jobs->dispatchScheduledMaintenance($runHealth, $runRecommendations, $runDuplicates);
foreach ($summary as $key => $payload) {
$this->info(sprintf('%s: %d queued.', ucfirst((string) $key), (int) ($payload['count'] ?? 0)));
}
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

@@ -11,7 +11,7 @@ use Illuminate\Support\Str;
class ImportLegacyUsers extends Command
{
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--dry-run : Preview which users would be skipped/deleted without making changes}';
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--restore-temp-usernames : Restore legacy usernames for existing users still using tmpu12345-style placeholders} {--dry-run : Preview which users would be skipped/deleted without making changes}';
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
protected string $migrationLogPath;
@@ -20,7 +20,7 @@ class ImportLegacyUsers extends Command
public function handle(): int
{
$this->migrationLogPath = storage_path('logs/username_migration.log');
$this->migrationLogPath = (string) storage_path('logs/username_migration.log');
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
// Build the set of legacy user IDs that have any meaningful activity.
@@ -134,8 +134,14 @@ class ImportLegacyUsers extends Command
{
$legacyId = (int) $row->user_id;
// Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB).
$username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
// Use legacy username as-is by default. Placeholder tmp usernames can be
// restored explicitly with --restore-temp-usernames using safe uniqueness rules.
$existingUser = DB::table('users')
->select(['id', 'username'])
->where('id', $legacyId)
->first();
$username = $this->resolveImportUsername($row, $legacyId, $existingUser?->username ?? null);
$normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? ''));
if ($normalizedLegacy !== $username) {
@@ -173,7 +179,12 @@ class ImportLegacyUsers extends Command
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
$now = now();
$alreadyExists = DB::table('users')->where('id', $legacyId)->exists();
$existingUser = DB::table('users')
->select(['id', 'username'])
->where('id', $legacyId)
->first();
$alreadyExists = $existingUser !== null;
$previousUsername = (string) ($existingUser?->username ?? '');
// All fields synced from legacy on every run
$sharedFields = [
@@ -212,7 +223,7 @@ class ImportLegacyUsers extends Command
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
'language' => $row->lang ?: null,
'birthdate' => $row->birth ?: null,
'gender' => $row->gender ?: 'X',
'gender' => $this->normalizeLegacyGender($row->gender ?? null),
'website' => $row->web ?: null,
'updated_at' => $now,
]
@@ -232,7 +243,7 @@ class ImportLegacyUsers extends Command
);
if (Schema::hasTable('username_redirects')) {
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
$old = $this->usernameRedirectKey((string) ($row->uname ?? ''));
if ($old !== '' && $old !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
@@ -244,10 +255,50 @@ class ImportLegacyUsers extends Command
]
);
}
if ($this->shouldRestoreTemporaryUsername($previousUsername) && $previousUsername !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $this->usernameRedirectKey($previousUsername)],
[
'new_username' => $username,
'user_id' => $legacyId,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
});
}
protected function resolveImportUsername(object $row, int $legacyId, ?string $existingUsername = null): string
{
$legacyUsername = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
if (! $this->option('restore-temp-usernames')) {
return $legacyUsername;
}
if ($existingUsername === null || $existingUsername === '') {
return $legacyUsername;
}
if (! $this->shouldRestoreTemporaryUsername($existingUsername)) {
return $existingUsername;
}
return UsernamePolicy::uniqueCandidate((string) ($row->uname ?: ('user' . $legacyId)), $legacyId);
}
protected function shouldRestoreTemporaryUsername(?string $username): bool
{
if (! is_string($username) || trim($username) === '') {
return false;
}
return preg_match('/^tmpu\d+$/i', trim($username)) === 1;
}
/**
* Ensure statistic values are safe for unsigned DB columns.
*/
@@ -265,6 +316,24 @@ class ImportLegacyUsers extends Command
return UsernamePolicy::sanitizeLegacy($username);
}
protected function usernameRedirectKey(?string $username): string
{
$value = $this->sanitizeUsername((string) ($username ?? ''));
return $value === 'user' && trim((string) ($username ?? '')) === '' ? '' : $value;
}
protected function normalizeLegacyGender(mixed $value): ?string
{
$normalized = strtoupper(trim((string) ($value ?? '')));
return match ($normalized) {
'M', 'MALE', 'MAN', 'BOY' => 'M',
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
default => null,
};
}
protected function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Services\Vision\VectorService;
use Illuminate\Console\Command;
final class IndexArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-index
{--start-id=0 : Start from this artwork id (inclusive)}
{--after-id=0 : Resume after this artwork id}
{--batch=100 : Batch size per iteration}
{--limit=0 : Maximum artworks to process in this run}
{--embedded-only : Re-upsert only artworks that already have local embeddings}
{--public-only : Index only public, approved, published artworks}
{--dry-run : Preview requests without sending them}';
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
public function handle(VectorService $vectors): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $vectors->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
$startId = max(0, (int) $this->option('start-id'));
$afterId = max(0, (int) $this->option('after-id'));
$batch = max(1, min((int) $this->option('batch'), 1000));
$limit = max(0, (int) $this->option('limit'));
$publicOnly = (bool) $this->option('public-only');
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
$embeddedOnly = (bool) $this->option('embedded-only');
$processed = 0;
$indexed = 0;
$skipped = 0;
$failed = 0;
$lastId = $afterId;
if ($startId > 0 && $afterId > 0) {
$this->warn(sprintf(
'Both --start-id=%d and --after-id=%d were provided. Using --start-id and ignoring --after-id.',
$startId,
$afterId
));
}
$this->info(sprintf(
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s embedded_only=%s public_only=%s dry_run=%s',
$startId,
$afterId,
$nextId,
$batch,
$limit > 0 ? (string) $limit : 'all',
$embeddedOnly ? 'yes' : 'no',
$publicOnly ? 'yes' : 'no',
$dryRun ? 'yes' : 'no'
));
while (true) {
$remaining = $limit > 0 ? max(0, $limit - $processed) : $batch;
if ($limit > 0 && $remaining === 0) {
break;
}
$take = $limit > 0 ? min($batch, $remaining) : $batch;
$query = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->where('id', '>=', $nextId)
->whereNotNull('hash')
->orderBy('id')
->limit($take);
if ($embeddedOnly) {
$query->whereHas('embeddings');
}
if ($publicOnly) {
$query->public()->published();
}
$artworks = $query->get();
if ($artworks->isEmpty()) {
$this->line('No more artworks matched the current query window.');
break;
}
$this->line(sprintf(
'Fetched batch: count=%d first_id=%d last_id=%d',
$artworks->count(),
(int) $artworks->first()->id,
(int) $artworks->last()->id
));
foreach ($artworks as $artwork) {
$processed++;
$lastId = (int) $artwork->id;
$nextId = $lastId + 1;
try {
$payload = $vectors->payloadForArtwork($artwork);
} catch (\Throwable $e) {
$skipped++;
$this->warn("Skipped artwork {$artwork->id}: {$e->getMessage()}");
continue;
}
$this->line(sprintf(
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
(int) $artwork->id,
(string) ($artwork->hash ?? ''),
(string) ($artwork->thumb_ext ?? ''),
$payload['url'],
$this->json($payload['metadata'])
));
if ($dryRun) {
$indexed++;
$this->line(sprintf(
'[dry] artwork=%d indexed=%d/%d',
(int) $artwork->id,
$indexed,
$processed
));
continue;
}
try {
$vectors->upsertArtwork($artwork);
$indexed++;
$this->info(sprintf(
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
(int) $artwork->id,
$processed,
$indexed,
$skipped,
$failed
));
} catch (\Throwable $e) {
$failed++;
$this->warn("Failed artwork {$artwork->id}: {$e->getMessage()}");
}
}
}
$this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @param array<string, string> $payload
*/
private function json(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($json) ? $json : '{}';
}
}

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,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,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,127 @@
<?php
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\ActivityEvent;
use App\Services\Activity\UserActivityService;
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 = $artwork->visibility !== Artwork::VISIBILITY_PRIVATE;
$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) {}
try {
app(UserActivityService::class)->logUpload((int) $artwork->user_id, (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,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

@@ -8,12 +8,12 @@ use App\Services\TrendingService;
use Illuminate\Console\Command;
/**
* php artisan skinbase:recalculate-trending [--period=24h|7d] [--chunk=1000] [--skip-index]
* php artisan skinbase:recalculate-trending [--period=1h|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.}
{--period=7d : Period to recalculate (1h, 24h or 7d). Use "all" to run all three.}
{--chunk=1000 : DB chunk size}
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
@@ -30,11 +30,11 @@ class RecalculateTrendingCommand extends Command
$chunkSize = (int) $this->option('chunk');
$skipIndex = (bool) $this->option('skip-index');
$periods = $period === 'all' ? ['24h', '7d'] : [$period];
$periods = $period === 'all' ? ['1h', '24h', '7d'] : [$period];
foreach ($periods as $p) {
if (! in_array($p, ['24h', '7d'], true)) {
$this->error("Invalid period '{$p}'. Use 24h, 7d, or all.");
if (! in_array($p, ['1h', '24h', '7d'], true)) {
$this->error("Invalid period '{$p}'. Use 1h, 24h, 7d, or all.");
return self::FAILURE;
}

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,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,342 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class RepairLegacyUserJoinDatesCommand extends Command
{
/** @var array<string, bool> */
private array $legacyTableExistsCache = [];
protected $signature = 'skinbase:repair-user-join-dates
{--chunk=500 : Number of users to process per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=users : Legacy users table name}
{--only-null : Update only users whose current created_at is null}
{--dry-run : Preview join date updates without writing changes}';
protected $description = 'Backfill current users.created_at from legacy users.joinDate';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$legacyConnection = (string) $this->option('legacy-connection');
$legacyTable = (string) $this->option('legacy-table');
$onlyNull = (bool) $this->option('only-null');
$dryRun = (bool) $this->option('dry-run');
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$query = DB::table('users')->select(['id', 'created_at']);
if ($onlyNull) {
$query->whereNull('created_at');
}
$this->info('Scanning current users for legacy joinDate backfill.');
$processed = 0;
$matched = 0;
$updated = 0;
$unchanged = 0;
$skipped = 0;
$query
->chunkById($chunk, function (Collection $rows) use (
&$processed,
&$matched,
&$updated,
&$unchanged,
&$skipped,
$legacyConnection,
$legacyTable,
$dryRun
): void {
$legacyById = $this->loadLegacyUsersForChunk($rows, $legacyConnection, $legacyTable);
$activityById = $this->loadLegacyActivityDatesForChunk($rows, $legacyConnection);
foreach ($rows as $row) {
$processed++;
$legacyMatch = $legacyById[(int) $row->id] ?? null;
if ($legacyMatch === null) {
$skipped++;
continue;
}
$matched++;
$legacyJoinDate = $this->parseLegacyJoinDate($legacyMatch->joinDate ?? null);
$dateSource = 'joinDate';
if ($legacyJoinDate === null) {
$activityFallback = $activityById[(int) $row->id] ?? null;
$legacyJoinDate = $activityFallback['date'] ?? null;
$dateSource = $activityFallback['source'] ?? 'activity';
}
if ($legacyJoinDate === null) {
$skipped++;
continue;
}
$currentCreatedAt = $this->parseCurrentDate($row->created_at ?? null);
if ($currentCreatedAt !== null && $currentCreatedAt->equalTo($legacyJoinDate)) {
$unchanged++;
continue;
}
if ($dryRun) {
$this->line(sprintf(
'[dry] Would update user id=%d created_at %s => %s (%s)',
(int) $row->id,
$currentCreatedAt?->toDateTimeString() ?? '<null>',
$legacyJoinDate->toDateTimeString(),
$dateSource
));
$updated++;
continue;
}
$affected = DB::table('users')
->where('id', (int) $row->id)
->update([
'created_at' => $legacyJoinDate->toDateTimeString(),
]);
if ($affected > 0) {
$updated += $affected;
$this->line(sprintf(
'[update] user id=%d created_at => %s (%s)',
(int) $row->id,
$legacyJoinDate->toDateTimeString(),
$dateSource
));
}
}
}, 'id');
$this->info(sprintf(
'Finished. processed=%d matched=%d updated=%d unchanged=%d skipped=%d',
$processed,
$matched,
$updated,
$unchanged,
$skipped
));
if ($processed === 0) {
$this->info('No users matched the requested scope.');
}
return self::SUCCESS;
}
private function legacyTableExists(string $connection, string $table): bool
{
$cacheKey = strtolower($connection . ':' . $table);
if (array_key_exists($cacheKey, $this->legacyTableExistsCache)) {
return $this->legacyTableExistsCache[$cacheKey];
}
try {
return $this->legacyTableExistsCache[$cacheKey] = DB::connection($connection)->getSchemaBuilder()->hasTable($table);
} catch (\Throwable) {
return $this->legacyTableExistsCache[$cacheKey] = false;
}
}
/**
* @return array<int, object>
*/
private function loadLegacyUsersForChunk(Collection $rows, string $legacyConnection, string $legacyTable): array
{
$legacyById = [];
$ids = $rows
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
if ($ids !== []) {
DB::connection($legacyConnection)
->table($legacyTable)
->select(['user_id', 'joinDate'])
->whereIn('user_id', $ids)
->get()
->each(function (object $legacyRow) use (&$legacyById): void {
$legacyById[(int) $legacyRow->user_id] = $legacyRow;
});
}
return $legacyById;
}
/**
* @return array<int, array{date: Carbon, source: string}>
*/
private function loadLegacyActivityDatesForChunk(Collection $rows, string $legacyConnection): array
{
$activityById = [];
$ids = $rows
->pluck('id')
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->values()
->all();
if ($ids === []) {
return $activityById;
}
if ($this->legacyTableExists($legacyConnection, 'wallz')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('wallz')
->selectRaw('user_id, MIN(datum) as first_at')
->whereIn('user_id', $ids)
->whereRaw("datum IS NOT NULL AND datum <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first upload'
);
}
if ($this->legacyTableExists($legacyConnection, 'forum_topics')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('forum_topics')
->selectRaw('user_id, MIN(post_date) as first_at')
->whereIn('user_id', $ids)
->whereRaw("post_date <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first forum topic'
);
}
if ($this->legacyTableExists($legacyConnection, 'forum_posts')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('forum_posts')
->selectRaw('user_id, MIN(post_date) as first_at')
->whereIn('user_id', $ids)
->whereRaw("post_date <> '0000-00-00 00:00:00'")
->groupBy('user_id')
->get(),
'first forum post'
);
}
if ($this->legacyTableExists($legacyConnection, 'artworks_comments')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('artworks_comments')
->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at")
->whereIn('user_id', $ids)
->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'")
->groupBy('user_id')
->get(),
'first artwork comment'
);
}
if ($this->legacyTableExists($legacyConnection, 'users_comments')) {
$this->registerChunkActivityDates(
$activityById,
DB::connection($legacyConnection)
->table('users_comments')
->selectRaw("user_id, MIN(TIMESTAMP(`date`, COALESCE(`time`, '00:00:00'))) as first_at")
->whereIn('user_id', $ids)
->whereRaw("`date` IS NOT NULL AND `date` <> '0000-00-00'")
->groupBy('user_id')
->get(),
'first profile comment'
);
}
return $activityById;
}
/**
* @param array<int, array{date: Carbon, source: string}> $activityById
*/
private function registerChunkActivityDates(array &$activityById, iterable $rows, string $source): void
{
foreach ($rows as $row) {
$candidate = $this->parseLegacyJoinDate($row->first_at ?? null);
if ($candidate === null) {
continue;
}
$userId = (int) ($row->user_id ?? 0);
if ($userId <= 0) {
continue;
}
$existing = $activityById[$userId]['date'] ?? null;
if ($existing === null || $candidate->lt($existing)) {
$activityById[$userId] = [
'date' => $candidate,
'source' => $source,
];
}
}
}
private function parseLegacyJoinDate(mixed $value): ?Carbon
{
$raw = trim((string) ($value ?? ''));
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
private function parseCurrentDate(mixed $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
$raw = trim((string) ($value ?? ''));
if ($raw === '') {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Carbon\Carbon;
class RepairLegacyWallzUsersCommand extends Command
{
protected $signature = 'skinbase:repair-legacy-wallz-users
{--chunk=500 : Number of legacy wallz rows to scan per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=wallz : Legacy table to update}
{--artworks-table=artworks : Current DB artworks table name}
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
{--dry-run : Preview matches and inserts without writing changes}';
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$legacyConnection = (string) $this->option('legacy-connection');
$legacyTable = (string) $this->option('legacy-table');
$artworksTable = (string) $this->option('artworks-table');
$fixArtworks = (bool) $this->option('fix-artworks');
$dryRun = (bool) $this->option('dry-run');
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
if ($fixArtworks) {
$this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun);
}
$total = (int) DB::connection($legacyConnection)
->table($legacyTable)
->where('user_id', 0)
->count();
if ($total === 0) {
if (! $fixArtworks) {
$this->info('No legacy wallz rows with user_id = 0 were found.');
}
return self::SUCCESS;
}
$this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}.");
$processed = 0;
$updatedRows = 0;
$matchedUsers = 0;
$createdUsers = 0;
$skippedRows = 0;
$usernameMap = [];
DB::connection($legacyConnection)
->table($legacyTable)
->select(['id', 'uname'])
->where('user_id', 0)
->orderBy('id')
->chunkById($chunk, function ($rows) use (
&$processed,
&$updatedRows,
&$matchedUsers,
&$createdUsers,
&$skippedRows,
&$usernameMap,
$dryRun,
$legacyConnection,
$legacyTable
) {
foreach ($rows as $row) {
$processed++;
$rawUsername = trim((string) ($row->uname ?? ''));
if ($rawUsername === '') {
$skippedRows++;
$this->warn("Skipping wallz id={$row->id}: uname is empty.");
continue;
}
$lookupKey = UsernamePolicy::normalize($rawUsername);
if ($lookupKey === '') {
$skippedRows++;
$this->warn("Skipping wallz id={$row->id}: uname normalizes to empty.");
continue;
}
if (! array_key_exists($lookupKey, $usernameMap)) {
$existingUser = $this->findUserByUsername($lookupKey);
if ($existingUser !== null) {
$usernameMap[$lookupKey] = [
'user_id' => (int) $existingUser->id,
'created' => false,
];
} else {
$usernameMap[$lookupKey] = [
'user_id' => $dryRun
? 0
: $this->createUserForLegacyUsername($rawUsername, $legacyConnection),
'created' => true,
];
}
}
$resolved = $usernameMap[$lookupKey];
if ($resolved['created']) {
$createdUsers++;
$usernameMap[$lookupKey]['created'] = false;
$resolved['created'] = false;
$this->line($dryRun
? "[dry] Would create user for uname='{$rawUsername}'"
: "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'");
} else {
$matchedUsers++;
}
if ($dryRun) {
$targetUser = $usernameMap[$lookupKey]['user_id'] > 0
? (string) $usernameMap[$lookupKey]['user_id']
: '<new-user-id>';
$this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'");
$updatedRows++;
continue;
}
$affected = DB::connection($legacyConnection)
->table($legacyTable)
->where('id', $row->id)
->where('user_id', 0)
->update([
'user_id' => $usernameMap[$lookupKey]['user_id'],
]);
if ($affected > 0) {
$updatedRows += $affected;
}
}
}, 'id');
$this->info(sprintf(
'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d',
$processed,
$updatedRows,
$matchedUsers,
$createdUsers,
$skippedRows
));
return self::SUCCESS;
}
private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void
{
$this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0");
$total = (int) DB::table($artworksTable)->where('user_id', 0)->count();
$this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}.");
$processed = 0;
$updated = 0;
DB::table($artworksTable)
->select(['id'])
->where('user_id', 0)
->orderBy('id')
->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) {
foreach ($rows as $row) {
$processed++;
$legacyUser = DB::connection($legacyConnection)
->table($legacyTable)
->where('id', $row->id)
->value('user_id');
$legacyUser = (int) ($legacyUser ?? 0);
if ($legacyUser <= 0) {
continue;
}
if ($dryRun) {
$this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}");
$updated++;
continue;
}
$affected = DB::table($artworksTable)
->where('id', $row->id)
->where('user_id', 0)
->update(['user_id' => $legacyUser]);
if ($affected > 0) {
$updated += $affected;
}
}
}, 'id');
$this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated));
}
private function legacyTableExists(string $connection, string $table): bool
{
try {
return DB::connection($connection)->getSchemaBuilder()->hasTable($table);
} catch (\Throwable) {
return false;
}
}
private function findUserByUsername(string $normalizedUsername): ?object
{
return DB::table('users')
->select(['id', 'username'])
->whereRaw('LOWER(username) = ?', [$normalizedUsername])
->first();
}
private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int
{
$username = UsernamePolicy::uniqueCandidate($legacyUsername);
$emailLocal = $this->sanitizeEmailLocal($username);
$email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org');
$now = now();
// Attempt to copy legacy joinDate from the legacy `users` table when available.
$legacyJoin = null;
try {
$legacyJoin = DB::connection($legacyConnection)
->table('users')
->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)])
->value('joinDate');
} catch (\Throwable) {
$legacyJoin = null;
}
$createdAt = $now;
if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) {
try {
$createdAt = Carbon::parse($legacyJoin);
} catch (\Throwable) {
$createdAt = $now;
}
}
$userId = (int) DB::table('users')->insertGetId([
'username' => $username,
'username_changed_at' => $now,
'name' => $legacyUsername,
'email' => $email,
'password' => Hash::make(Str::random(64)),
'is_active' => true,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'created_at' => $createdAt,
'updated_at' => $now,
]);
return $userId;
}
private function uniqueEmailCandidate(string $email): string
{
$candidate = strtolower(trim($email));
$suffix = 1;
while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) {
$parts = explode('@', $email, 2);
$local = $parts[0] ?? 'user';
$domain = $parts[1] ?? 'users.skinbase.org';
$candidate = $local . '+' . $suffix . '@' . $domain;
$suffix++;
}
return $candidate;
}
private function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairTemporaryUsernamesCommand extends Command
{
protected $signature = 'skinbase:repair-temp-usernames
{--chunk=500 : Number of users to process per batch}
{--dry-run : Preview username changes without writing them}';
protected $description = 'Replace current users.username values like tmpu% using the users.name field';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$total = (int) DB::table('users')
->where('username', 'like', 'tmpu%')
->count();
if ($total === 0) {
$this->info('No users with temporary tmpu% usernames were found.');
return self::SUCCESS;
}
$this->info("Found {$total} users with temporary tmpu% usernames.");
$processed = 0;
$updated = 0;
$skipped = 0;
DB::table('users')
->select(['id', 'name', 'username'])
->where('username', 'like', 'tmpu%')
->chunkById($chunk, function ($rows) use (&$processed, &$updated, &$skipped, $dryRun) {
foreach ($rows as $row) {
$processed++;
$sourceName = trim((string) ($row->name ?? ''));
if ($sourceName === '') {
$skipped++;
$this->warn("Skipping user id={$row->id}: name is empty.");
continue;
}
$candidate = $this->resolveCandidate($sourceName, (int) $row->id);
if ($candidate === null || strcasecmp($candidate, (string) $row->username) === 0) {
$skipped++;
$this->warn("Skipping user id={$row->id}: unable to resolve a better username from name='{$sourceName}'.");
continue;
}
if ($dryRun) {
$this->line("[dry] Would update user id={$row->id} username '{$row->username}' => '{$candidate}'");
$updated++;
continue;
}
$affected = DB::table('users')
->where('id', (int) $row->id)
->where('username', 'like', 'tmpu%')
->update([
'username' => $candidate,
'username_changed_at' => now(),
'updated_at' => now(),
]);
if ($affected > 0) {
$updated += $affected;
$this->line("[update] user id={$row->id} username '{$row->username}' => '{$candidate}'");
}
}
}, 'id');
$this->info(sprintf('Finished. processed=%d updated=%d skipped=%d', $processed, $updated, $skipped));
return self::SUCCESS;
}
private function resolveCandidate(string $sourceName, int $userId): ?string
{
$base = UsernamePolicy::sanitizeLegacy($sourceName);
$min = UsernamePolicy::min();
$max = UsernamePolicy::max();
if ($base === '') {
return null;
}
if (preg_match('/^tmpu\d+$/i', $base) === 1) {
$base = 'user' . $userId;
}
if (strlen($base) < $min) {
$base = substr($base . $userId, 0, $max);
}
if ($base === '' || $base === 'user') {
$base = 'user' . $userId;
}
$candidate = substr($base, 0, $max);
$suffix = 1;
while ($this->usernameExists($candidate, $userId) || UsernamePolicy::isReserved($candidate)) {
$suffixValue = (string) $suffix;
$prefixLen = max(1, $max - strlen($suffixValue));
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
$suffix++;
}
return $candidate;
}
private function usernameExists(string $username, int $ignoreUserId): bool
{
return DB::table('users')
->whereRaw('LOWER(username) = ?', [strtolower($username)])
->where('id', '!=', $ignoreUserId)
->exists();
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\VectorService;
use Illuminate\Console\Command;
final class SearchArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-search
{artwork_id : Source artwork id}
{--limit=5 : Number of similar artworks to return}';
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
public function handle(VectorService $vectors): int
{
if (! $vectors->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
$artworkId = max(1, (int) $this->argument('artwork_id'));
$limit = max(1, min((int) $this->option('limit'), 100));
$artwork = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->find($artworkId);
if (! $artwork) {
$this->error("Artwork {$artworkId} was not found.");
return self::FAILURE;
}
try {
$matches = $vectors->similarToArtwork($artwork, $limit);
} catch (\Throwable $e) {
$this->error('Vector search failed: ' . $e->getMessage());
return self::FAILURE;
}
$ids = collect($matches)->pluck('id')->map(fn (mixed $id): int => (int) $id)->filter()->values()->all();
if ($ids === []) {
$this->warn('No similar artworks were returned by the vector gateway.');
return self::SUCCESS;
}
$artworks = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->whereIn('id', $ids)
->public()
->published()
->get()
->keyBy('id');
$rows = [];
foreach ($matches as $match) {
$matchId = (int) ($match['id'] ?? 0);
if ($matchId <= 0) {
continue;
}
/** @var Artwork|null $matchedArtwork */
$matchedArtwork = $artworks->get($matchId);
if (! $matchedArtwork) {
continue;
}
$category = $this->primaryCategory($matchedArtwork);
$rows[] = [
'id' => $matchId,
'score' => number_format((float) ($match['score'] ?? 0.0), 4, '.', ''),
'title' => (string) $matchedArtwork->title,
'content_type' => (string) ($category?->contentType?->name ?? ''),
'category' => (string) ($category?->name ?? ''),
];
if (count($rows) >= $limit) {
break;
}
}
if ($rows === []) {
$this->warn('The vector gateway returned matches, but none resolved to public published artworks.');
return self::SUCCESS;
}
$this->table(['ID', 'Score', 'Title', 'Content Type', 'Category'], $rows);
return self::SUCCESS;
}
private function primaryCategory(Artwork $artwork): ?Category
{
/** @var Category|null $category */
$category = $artwork->categories->sortBy('sort_order')->first();
return $category;
}
}

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,46 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Collection;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionLifecycleService;
use App\Services\CollectionSurfaceService;
use Illuminate\Console\Command;
class SyncCollectionLifecycleCommand extends Command
{
protected $signature = 'collections:sync-lifecycle';
protected $description = 'Expire pending collection invites, sync collection lifecycle states, and deactivate expired placements.';
public function handle(CollectionCollaborationService $collaborators, CollectionLifecycleService $lifecycle, CollectionSurfaceService $surfaces): int
{
$expiredInvites = $collaborators->expirePendingInvites();
$lifecycleResults = $lifecycle->syncScheduledCollections();
$expiredPlacements = $surfaces->syncPlacements();
$unfeaturedCollections = Collection::query()
->where('is_featured', true)
->whereNotNull('unpublished_at')
->where('unpublished_at', '<=', now())
->update([
'is_featured' => false,
'featured_at' => null,
'updated_at' => now(),
]);
$this->info(sprintf(
'Expired %d pending invites; published %d scheduled collections; expired %d collections; unfeatured %d unpublished collections; deactivated %d expired placements.',
$expiredInvites,
(int) ($lifecycleResults['scheduled'] ?? 0),
(int) ($lifecycleResults['expired'] ?? 0),
$unfeaturedCollections,
$expiredPlacements,
));
return self::SUCCESS;
}
}

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,149 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
final class TestObjectStorageUploadCommand extends Command
{
protected $signature = 'storage:test-upload
{--disk=s3 : Filesystem disk to test}
{--file= : Optional absolute or relative path to an existing local file to upload}
{--path= : Optional remote object key to use}
{--keep : Keep the uploaded test object instead of deleting it afterwards}';
protected $description = 'Upload a probe file to the configured object storage disk and verify that it was stored.';
public function handle(): int
{
$diskName = (string) $this->option('disk');
$diskConfig = config("filesystems.disks.{$diskName}");
if (! is_array($diskConfig)) {
$this->error("Filesystem disk [{$diskName}] is not configured.");
return self::FAILURE;
}
$this->line('Testing object storage upload.');
$this->line('Disk: '.$diskName);
$this->line('Driver: '.(string) ($diskConfig['driver'] ?? 'unknown'));
$this->line('Bucket: '.(string) ($diskConfig['bucket'] ?? 'n/a'));
$this->line('Region: '.(string) ($diskConfig['region'] ?? 'n/a'));
$this->line('Endpoint: '.((string) ($diskConfig['endpoint'] ?? '') !== '' ? (string) $diskConfig['endpoint'] : '[not set]'));
$this->line('Path style: '.((bool) ($diskConfig['use_path_style_endpoint'] ?? false) ? 'true' : 'false'));
if ((string) ($diskConfig['endpoint'] ?? '') === '') {
$this->warn('No endpoint is configured for this S3 disk. Many S3-compatible providers, including Contabo object storage, require AWS_ENDPOINT to be set.');
}
$remotePath = $this->resolveRemotePath();
$keepObject = (bool) $this->option('keep');
$sourceFile = $this->option('file');
$filesystem = Storage::disk($diskName);
try {
if (is_string($sourceFile) && trim($sourceFile) !== '') {
$localPath = $this->resolveLocalPath($sourceFile);
if ($localPath === null) {
$this->error('The file passed to --file does not exist.');
return self::FAILURE;
}
$stream = fopen($localPath, 'rb');
if ($stream === false) {
$this->error('Unable to open the local file for reading.');
return self::FAILURE;
}
try {
$written = $filesystem->put($remotePath, $stream);
} finally {
fclose($stream);
}
$sourceLabel = $localPath;
} else {
$contents = $this->buildProbeContents($diskName);
$written = $filesystem->put($remotePath, $contents);
$sourceLabel = '[generated probe payload]';
}
if ($written !== true) {
$this->error('Upload did not complete successfully. The storage driver returned a failure status.');
return self::FAILURE;
}
$exists = $filesystem->exists($remotePath);
$size = $exists ? $filesystem->size($remotePath) : null;
$this->info('Upload succeeded.');
$this->line('Source: '.$sourceLabel);
$this->line('Object key: '.$remotePath);
$this->line('Exists after upload: '.($exists ? 'yes' : 'no'));
if ($size !== null) {
$this->line('Stored size: '.number_format((int) $size).' bytes');
}
if (! $keepObject && $exists) {
$filesystem->delete($remotePath);
$this->line('Cleanup: deleted uploaded test object');
} elseif ($keepObject) {
$this->warn('Cleanup skipped because --keep was used.');
}
return $exists ? self::SUCCESS : self::FAILURE;
} catch (Throwable $exception) {
$this->error('Object storage test failed.');
$this->line($exception->getMessage());
return self::FAILURE;
}
}
private function resolveRemotePath(): string
{
$provided = trim((string) $this->option('path'));
if ($provided !== '') {
return ltrim(str_replace('\\', '/', $provided), '/');
}
return 'tests/object-storage/'.now()->format('Ymd-His').'-'.Str::random(10).'.txt';
}
private function resolveLocalPath(string $path): ?string
{
$trimmed = trim($path);
if ($trimmed === '') {
return null;
}
if (is_file($trimmed)) {
return $trimmed;
}
$relative = base_path($trimmed);
return is_file($relative) ? $relative : null;
}
private function buildProbeContents(string $diskName): string
{
return implode("\n", [
'Skinbase object storage upload test',
'disk='.$diskName,
'timestamp='.now()->toIso8601String(),
'app_url='.(string) config('app.url'),
'random='.Str::uuid()->toString(),
'',
]);
}
}

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

@@ -7,15 +7,30 @@ use App\Console\Commands\ImportLegacyUsers;
use App\Console\Commands\ImportCategories;
use App\Console\Commands\MigrateFeaturedWorks;
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
use App\Console\Commands\BackfillArtworkVectorIndexCommand;
use App\Console\Commands\IndexArtworkVectorsCommand;
use App\Console\Commands\SearchArtworkVectorsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
use App\Console\Commands\SeedTagInteractionDemoCommand;
use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\SyncCountriesCommand;
use App\Console\Commands\CompareFeedAbCommand;
use App\Console\Commands\DispatchCollectionMaintenanceCommand;
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\RebuildTrendingNovaCardsJob;
use App\Jobs\RecalculateRisingNovaCardsJob;
use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand;
use App\Console\Commands\PublishScheduledArtworksCommand;
use App\Console\Commands\SyncCollectionLifecycleCommand;
class Kernel extends ConsoleKernel
{
@@ -30,16 +45,29 @@ class Kernel extends ConsoleKernel
ImportCategories::class,
MigrateFeaturedWorks::class,
\App\Console\Commands\AvatarsMigrate::class,
\App\Console\Commands\AvatarsBulkUpdate::class,
\App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class,
PublishScheduledArtworksCommand::class,
SyncCollectionLifecycleCommand::class,
DispatchCollectionMaintenanceCommand::class,
BackfillArtworkEmbeddingsCommand::class,
BackfillArtworkVectorIndexCommand::class,
IndexArtworkVectorsCommand::class,
SearchArtworkVectorsCommand::class,
AggregateSimilarArtworkAnalyticsCommand::class,
AggregateFeedAnalyticsCommand::class,
AggregateTagInteractionAnalyticsCommand::class,
SeedTagInteractionDemoCommand::class,
EvaluateFeedWeightsCommand::class,
CompareFeedAbCommand::class,
AiTagArtworksCommand::class,
SyncCountriesCommand::class,
\App\Console\Commands\MigrateFollows::class,
RecalculateTrendingCommand::class,
RecalculateRankingsCommand::class,
MetricsSnapshotHourlyCommand::class,
RecalculateHeatCommand::class,
];
/**
@@ -48,8 +76,26 @@ class Kernel extends ConsoleKernel
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
$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('collections:sync-lifecycle')
->everyTenMinutes()
->name('sync-collection-lifecycle')
->withoutOverlapping()
->runInBackground();
$schedule->command('collections:dispatch-maintenance')
->hourly()
->name('dispatch-collection-maintenance')
->withoutOverlapping()
->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();
@@ -59,6 +105,54 @@ class Kernel extends ConsoleKernel
$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();
$schedule->job(new RebuildTrendingNovaCardsJob)
->hourlyAt(25)
->name('nova-cards-trending-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 2b: bust Nova Cards v3 rising feed cache to stay in sync
$schedule->job(new RecalculateRisingNovaCardsJob)
->everyFifteenMinutes()
->name('nova-cards-rising-cache-refresh')
->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,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\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionArtworkAttached
{
use Dispatchable, SerializesModels;
/**
* @param array<int, int> $artworkIds
*/
public function __construct(public readonly Collection $collection, public readonly array $artworkIds) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionArtworkRemoved
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $artworkId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionCreated
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionDeleted
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionFeatured
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionFollowed
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionLiked
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionShared
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly ?int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionUnfeatured
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionUnfollowed
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionUnliked
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly int $userId) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionUpdated
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CollectionViewed
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection, public readonly ?int $viewerId = null) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\Collections;
use App\Models\Collection;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SmartCollectionRulesUpdated
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Collection $collection) {}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Events;
use App\Models\Conversation;
use App\Services\Messaging\MessagingPayloadFactory;
use App\Services\Messaging\UnreadCounterService;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ConversationUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public int $userId,
public Conversation $conversation,
public string $reason,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
}
public function broadcastOn(): array
{
return [new PrivateChannel('user.' . $this->userId)];
}
public function broadcastAs(): string
{
return 'conversation.updated';
}
public function broadcastWith(): array
{
return [
'event' => 'conversation.updated',
'reason' => $this->reason,
'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId),
'summary' => [
'unread_total' => app(UnreadCounterService::class)->totalUnreadForUser($this->userId),
],
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Events;
use App\Models\Conversation;
use App\Models\Message;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public Conversation $conversation,
public Message $message,
int $originUserId,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
if ($originUserId === (int) $message->sender_id) {
$this->dontBroadcastToCurrentUser();
}
}
public function broadcastOn(): array
{
return [new PrivateChannel('conversation.' . $this->conversation->id)];
}
public function broadcastAs(): string
{
return 'message.created';
}
public function broadcastWith(): array
{
return [
'event' => 'message.created',
'conversation_id' => (int) $this->conversation->id,
'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageDeleted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(public Message $message)
{
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PrivateChannel('conversation.' . $this->message->conversation_id)];
}
public function broadcastAs(): string
{
return 'message.deleted';
}
public function broadcastWith(): array
{
return [
'event' => 'message.deleted',
'conversation_id' => (int) $this->message->conversation_id,
'message_id' => (int) $this->message->id,
'uuid' => (string) $this->message->uuid,
'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Events;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\User;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageRead implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public Conversation $conversation,
public ConversationParticipant $participant,
public User $reader,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PrivateChannel('conversation.' . $this->conversation->id)];
}
public function broadcastAs(): string
{
return 'message.read';
}
public function broadcastWith(): array
{
return [
'event' => 'message.read',
'conversation_id' => (int) $this->conversation->id,
'user' => app(MessagingPayloadFactory::class)->userSummary($this->reader),
'last_read_message_id' => $this->participant->last_read_message_id ? (int) $this->participant->last_read_message_id : null,
'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(),
];
}
}

View File

@@ -1,19 +0,0 @@
<?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,44 @@
<?php
namespace App\Events;
use App\Models\Message;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(public Message $message)
{
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PrivateChannel('conversation.' . $this->message->conversation_id)];
}
public function broadcastAs(): string
{
return 'message.updated';
}
public function broadcastWith(): array
{
return [
'event' => 'message.updated',
'conversation_id' => (int) $this->message->conversation_id,
'message' => app(MessagingPayloadFactory::class)->message($this->message),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardAutosaved
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public array $changedFields = []) {}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use App\Models\NovaCardBackground;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardBackgroundUploaded
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public NovaCardBackground $background) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardCreated
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardDownloaded
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public ?int $viewerId = null) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardPublished
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardShared
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public ?int $viewerId = null) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardTemplateSelected
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public ?int $previousTemplateId = null, public ?int $templateId = null) {}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Events\NovaCards;
use App\Models\NovaCard;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NovaCardViewed
{
use Dispatchable, SerializesModels;
public function __construct(public NovaCard $card, public ?int $viewerId = null) {}
}

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

@@ -2,15 +2,46 @@
namespace App\Events;
use App\Models\User;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TypingStarted
class TypingStarted implements ShouldBroadcast
{
use Dispatchable, SerializesModels;
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public int $conversationId,
public int $userId,
) {}
public User $user,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PresenceChannel('conversation.' . $this->conversationId)];
}
public function broadcastAs(): string
{
return 'typing.started';
}
public function broadcastWith(): array
{
return [
'event' => 'typing.started',
'conversation_id' => $this->conversationId,
'user' => app(MessagingPayloadFactory::class)->userSummary($this->user),
'expires_in_ms' => (int) config('messaging.typing.ttl_seconds', 8) * 1000,
];
}
}

View File

@@ -2,15 +2,45 @@
namespace App\Events;
use App\Models\User;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TypingStopped
class TypingStopped implements ShouldBroadcast
{
use Dispatchable, SerializesModels;
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public int $conversationId,
public int $userId,
) {}
public User $user,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PresenceChannel('conversation.' . $this->conversationId)];
}
public function broadcastAs(): string
{
return 'typing.stopped';
}
public function broadcastWith(): array
{
return [
'event' => 'typing.stopped',
'conversation_id' => $this->conversationId,
'user' => app(MessagingPayloadFactory::class)->userSummary($this->user),
];
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Models\CollectionMember;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionModerationService;
use App\Services\CollectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class CollectionModerationController extends Controller
{
public function __construct(
private readonly CollectionModerationService $moderation,
private readonly CollectionService $collections,
private readonly CollectionCollaborationService $collaborators,
) {
}
public function updateModeration(Request $request, Collection $collection): JsonResponse
{
$data = $request->validate([
'moderation_status' => ['required', 'in:active,under_review,restricted,hidden'],
]);
$collection = $this->moderation->updateStatus($collection->loadMissing('user'), (string) $data['moderation_status']);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function updateInteractions(Request $request, Collection $collection): JsonResponse
{
$data = $request->validate([
'allow_comments' => ['sometimes', 'boolean'],
'allow_submissions' => ['sometimes', 'boolean'],
'allow_saves' => ['sometimes', 'boolean'],
]);
$collection = $this->moderation->updateInteractions($collection->loadMissing('user'), $data);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function unfeature(Request $request, Collection $collection): JsonResponse
{
$collection = $this->moderation->unfeature($collection->loadMissing('user'));
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function destroyMember(Request $request, Collection $collection, CollectionMember $member): JsonResponse
{
$this->moderation->removeMember($collection, $member);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->fresh()->loadMissing('user'), true),
'members' => $this->collaborators->mapMembers($collection->fresh(), $request->user()),
]);
}
}

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\DiscoveryFeedbackReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class DiscoveryFeedbackReportController extends Controller
{
public function __construct(private readonly DiscoveryFeedbackReportService $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'] ?? 20);
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(),
],
'overview' => $report['overview'],
'daily_feedback' => $report['daily_feedback'],
'trend_summary' => $report['trend_summary'],
'by_surface' => $report['by_surface'],
'by_algo_surface' => $report['by_algo_surface'],
'top_artworks' => $report['top_artworks'],
'latest_aggregated_date' => $report['latest_aggregated_date'],
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\Recommendations\RecommendationFeedResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class FeedEngineDecisionController extends Controller
{
public function __construct(private readonly RecommendationFeedResolver $feedResolver) {}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
]);
$userId = (int) $validated['user_id'];
$algoVersion = isset($validated['algo_version']) ? (string) $validated['algo_version'] : null;
return response()->json([
'meta' => [
'generated_at' => now()->toISOString(),
],
'decision' => $this->feedResolver->inspectDecision($userId, $algoVersion),
], Response::HTTP_OK);
}
}

View File

@@ -3,23 +3,231 @@
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Models\Report;
use App\Models\ReportHistory;
use App\Services\NovaCards\NovaCardPublishModerationService;
use App\Support\Moderation\ReportTargetResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class ModerationReportQueueController extends Controller
{
public function __construct(
private readonly ReportTargetResolver $targets,
private readonly NovaCardPublishModerationService $moderation,
) {}
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', 'open');
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
$group = (string) $request->query('group', '');
$items = Report::query()
->with('reporter:id,username')
$query = Report::query()
->with(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username'])
->where('status', $status)
->orderByDesc('id')
->paginate(30);
->orderByDesc('id');
return response()->json($items);
if ($group === 'nova_cards') {
$query->whereIn('target_type', $this->targets->novaCardTargetTypes());
}
$items = $query->paginate(30);
return response()->json([
'data' => collect($items->items())
->map(fn (Report $report): array => $this->serializeReport($report))
->values()
->all(),
'meta' => [
'current_page' => $items->currentPage(),
'last_page' => $items->lastPage(),
'per_page' => $items->perPage(),
'total' => $items->total(),
'from' => $items->firstItem(),
'to' => $items->lastItem(),
],
]);
}
public function update(Request $request, Report $report): JsonResponse
{
$data = $request->validate([
'status' => 'sometimes|in:open,reviewing,closed',
'moderator_note' => 'sometimes|nullable|string|max:2000',
]);
$before = [];
$after = [];
$user = $request->user();
DB::transaction(function () use ($data, $report, $user, &$before, &$after): void {
if (array_key_exists('status', $data) && $data['status'] !== $report->status) {
$before['status'] = (string) $report->status;
$after['status'] = (string) $data['status'];
$report->status = $data['status'];
}
if (array_key_exists('moderator_note', $data)) {
$normalizedNote = is_string($data['moderator_note']) ? trim($data['moderator_note']) : null;
$normalizedNote = $normalizedNote !== '' ? $normalizedNote : null;
if ($normalizedNote !== $report->moderator_note) {
$before['moderator_note'] = $report->moderator_note;
$after['moderator_note'] = $normalizedNote;
$report->moderator_note = $normalizedNote;
}
}
if ($before !== [] || $after !== []) {
$report->last_moderated_by_id = $user?->id;
$report->last_moderated_at = now();
$report->save();
$report->historyEntries()->create([
'actor_user_id' => $user?->id,
'action_type' => 'report_updated',
'summary' => $this->buildUpdateSummary($before, $after),
'note' => $report->moderator_note,
'before_json' => $before !== [] ? $before : null,
'after_json' => $after !== [] ? $after : null,
'created_at' => now(),
]);
}
});
$report = $report->fresh(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username']);
return response()->json([
'report' => $this->serializeReport($report),
]);
}
public function moderateTarget(Request $request, Report $report): JsonResponse
{
$data = $request->validate([
'action' => 'required|in:approve_card,flag_card,reject_card',
'disposition' => 'nullable|in:' . implode(',', array_keys(NovaCardPublishModerationService::DISPOSITION_LABELS)),
]);
$card = $this->targets->resolveModerationCard($report);
abort_unless($card !== null, 422, 'This report does not have a Nova Card moderation target.');
DB::transaction(function () use ($card, $data, $report, $request): void {
$before = [
'card_id' => (int) $card->id,
'moderation_status' => (string) $card->moderation_status,
];
$nextStatus = match ($data['action']) {
'approve_card' => NovaCard::MOD_APPROVED,
'flag_card' => NovaCard::MOD_FLAGGED,
'reject_card' => NovaCard::MOD_REJECTED,
};
$card = $this->moderation->recordStaffOverride(
$card,
$nextStatus,
$request->user(),
'report_queue',
[
'note' => $report->moderator_note,
'report_id' => $report->id,
'disposition' => $data['disposition'] ?? null,
],
);
$report->last_moderated_by_id = $request->user()?->id;
$report->last_moderated_at = now();
$report->save();
$report->historyEntries()->create([
'actor_user_id' => $request->user()?->id,
'action_type' => 'target_moderated',
'summary' => $this->buildTargetModerationSummary($data['action'], $card),
'note' => $report->moderator_note,
'before_json' => $before,
'after_json' => [
'card_id' => (int) $card->id,
'moderation_status' => (string) $card->moderation_status,
'action' => (string) $data['action'],
],
'created_at' => now(),
]);
});
$report = $report->fresh(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username']);
return response()->json([
'report' => $this->serializeReport($report),
]);
}
private function buildUpdateSummary(array $before, array $after): string
{
$parts = [];
if (array_key_exists('status', $after)) {
$parts[] = sprintf('Status %s -> %s', $before['status'], $after['status']);
}
if (array_key_exists('moderator_note', $after)) {
$parts[] = $after['moderator_note'] ? 'Moderator note updated' : 'Moderator note cleared';
}
return $parts !== [] ? implode(' • ', $parts) : 'Report reviewed';
}
private function buildTargetModerationSummary(string $action, NovaCard $card): string
{
return match ($action) {
'approve_card' => sprintf('Approved card #%d', $card->id),
'flag_card' => sprintf('Flagged card #%d', $card->id),
'reject_card' => sprintf('Rejected card #%d', $card->id),
default => sprintf('Updated card #%d', $card->id),
};
}
private function serializeReport(Report $report): array
{
return [
'id' => (int) $report->id,
'status' => (string) $report->status,
'target_type' => (string) $report->target_type,
'target_id' => (int) $report->target_id,
'reason' => (string) $report->reason,
'details' => $report->details,
'moderator_note' => $report->moderator_note,
'created_at' => optional($report->created_at)?->toISOString(),
'updated_at' => optional($report->updated_at)?->toISOString(),
'last_moderated_at' => optional($report->last_moderated_at)?->toISOString(),
'reporter' => $report->reporter ? [
'id' => (int) $report->reporter->id,
'username' => (string) $report->reporter->username,
] : null,
'last_moderated_by' => $report->lastModeratedBy ? [
'id' => (int) $report->lastModeratedBy->id,
'username' => (string) $report->lastModeratedBy->username,
] : null,
'target' => $this->targets->summarize($report),
'history' => $report->historyEntries
->take(8)
->map(fn (ReportHistory $entry): array => [
'id' => (int) $entry->id,
'action_type' => (string) $entry->action_type,
'summary' => $entry->summary,
'note' => $entry->note,
'before' => $entry->before_json,
'after' => $entry->after_json,
'created_at' => optional($entry->created_at)?->toISOString(),
'actor' => $entry->actor ? [
'id' => (int) $entry->actor->id,
'username' => (string) $entry->actor->username,
] : null,
])
->values()
->all(),
];
}
}

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

@@ -10,6 +10,7 @@ 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
@@ -124,6 +125,9 @@ final class UsernameApprovalController extends Controller
$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 !== '') {

View File

@@ -3,10 +3,14 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService;
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\Services\LegacySmileyMapper;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
@@ -113,6 +117,7 @@ class ArtworkCommentController extends Controller
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 {
@@ -124,6 +129,15 @@ class ArtworkCommentController extends Controller
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logComment(
(int) $request->user()->id,
(int) $comment->id,
$parentId !== null,
['artwork_id' => (int) $artwork->id],
);
} catch (\Throwable) {}
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
}
@@ -193,7 +207,7 @@ class ArtworkCommentController extends Controller
'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 ?? '')),
'rendered_content' => $this->renderCommentContent($c),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId,
@@ -204,6 +218,8 @@ class ArtworkCommentController extends Controller
'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'),
],
];
@@ -217,4 +233,73 @@ class ArtworkCommentController extends Controller
return $data;
}
private function renderCommentContent(ArtworkComment $comment): string
{
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
$renderedContent = $comment->rendered_content;
if (! is_string($renderedContent) || trim($renderedContent) === '') {
$renderedContent = $rawContent !== ''
? ContentSanitizer::render($rawContent)
: nl2br(e(strip_tags((string) ($comment->content ?? ''))));
}
return ContentSanitizer::sanitizeRenderedHtml(
$renderedContent,
$this->commentAuthorCanPublishLinks($comment)
);
}
private function commentAuthorCanPublishLinks(ArtworkComment $comment): bool
{
$level = (int) ($comment->user?->level ?? 1);
$rank = strtolower((string) ($comment->user?->rank ?? 'Newbie'));
return $level > 1 && $rank !== 'newbie';
}
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
{
$notifiedUserIds = [];
$creatorId = (int) ($artwork->user_id ?? 0);
if ($creatorId > 0 && $creatorId !== (int) $actor->id) {
$creator = User::query()->find($creatorId);
if ($creator) {
$creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
$notifiedUserIds[] = (int) $creator->id;
}
}
if ($parentId) {
$parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0);
if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) {
$parentUser = User::query()->find($parentUserId);
if ($parentUser) {
$parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
$notifiedUserIds[] = (int) $parentUser->id;
}
}
}
User::query()
->whereIn(
'id',
UserMention::query()
->where('comment_id', (int) $comment->id)
->pluck('mentioned_user_id')
->map(fn ($id) => (int) $id)
->unique()
->all()
)
->get()
->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void {
if ((int) $mentionedUser->id === (int) $actor->id) {
return;
}
$mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor));
});
}
}

View File

@@ -29,10 +29,16 @@ class ArtworkController extends Controller
$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
isset($data['description']) ? (string) $data['description'] : null,
$categoryId,
(bool) ($data['is_mature'] ?? false)
);
return response()->json([

View File

@@ -86,7 +86,9 @@ final class ArtworkDownloadController extends Controller
'artwork_id' => $artwork->id,
'user_id' => $request->user()?->id,
'ip' => $bin !== false ? $bin : null,
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
'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) {

View File

@@ -4,9 +4,14 @@ 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\Activity\UserActivityService;
use App\Services\UserStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -14,11 +19,25 @@ 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);
$this->toggleSimple(
$changed = $this->toggleSimple(
request: $request,
table: 'artwork_favourites',
keyColumns: ['user_id', 'artwork_id'],
@@ -33,7 +52,7 @@ final class ArtworkInteractionController extends Controller
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if ($creatorId) {
$svc = app(UserStatsService::class);
if ($state) {
if ($state && $changed) {
$svc->incrementFavoritesReceived($creatorId);
$svc->setLastActiveAt((int) $request->user()->id);
@@ -46,7 +65,11 @@ final class ArtworkInteractionController extends Controller
targetId: $artworkId,
);
} catch (\Throwable) {}
} else {
try {
app(UserActivityService::class)->logFavourite((int) $request->user()->id, $artworkId);
} catch (\Throwable) {}
} elseif (! $state && $changed) {
$svc->decrementFavoritesReceived($creatorId);
}
}
@@ -56,7 +79,7 @@ final class ArtworkInteractionController extends Controller
public function like(Request $request, int $artworkId): JsonResponse
{
$this->toggleSimple(
$changed = $this->toggleSimple(
request: $request,
table: 'artwork_likes',
keyColumns: ['user_id', 'artwork_id'],
@@ -67,6 +90,23 @@ final class ArtworkInteractionController extends Controller
$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;
try {
app(UserActivityService::class)->logLike($actorId, $artworkId);
} catch (\Throwable) {}
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));
}
@@ -104,8 +144,10 @@ final class ArtworkInteractionController extends Controller
return response()->json(['message' => 'Cannot follow yourself'], 422);
}
$svc = app(FollowService::class);
$state = $request->boolean('state', true);
$svc = app(FollowService::class);
$state = $request->has('state')
? $request->boolean('state')
: ! $request->isMethod('delete');
if ($state) {
$svc->follow($actorId, $userId);
@@ -148,7 +190,7 @@ final class ArtworkInteractionController extends Controller
array $keyValues,
array $insertPayload,
string $requiredTable
): void {
): bool {
if (! Schema::hasTable($requiredTable)) {
abort(422, 'Interaction unavailable');
}
@@ -163,10 +205,13 @@ final class ArtworkInteractionController extends Controller
if ($state) {
if (! $query->exists()) {
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
return true;
}
} else {
$query->delete();
return $query->delete() > 0;
}
return false;
}
private function syncArtworkStats(int $artworkId): void
@@ -194,6 +239,10 @@ final class ArtworkInteractionController extends Controller
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;
@@ -206,15 +255,21 @@ final class ArtworkInteractionController extends Controller
? (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

@@ -7,6 +7,7 @@ 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;
@@ -26,7 +27,10 @@ use Illuminate\Http\Request;
*/
final class ArtworkViewController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __construct(
private readonly ArtworkStatsService $stats,
private readonly XPService $xp,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
@@ -52,6 +56,16 @@ final class ArtworkViewController extends Controller
// 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);

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

@@ -17,7 +17,7 @@ final class DiscoveryEventController extends Controller
{
$payload = $request->validate([
'event_id' => ['nullable', 'uuid'],
'event_type' => ['required', 'string', 'in:view,click,favorite,download'],
'event_type' => ['required', 'string', 'in:view,click,favorite,download,dwell,scroll'],
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'occurred_at' => ['nullable', 'date'],
'algo_version' => ['nullable', 'string', 'max:64'],

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use App\Models\UserNegativeSignal;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class DiscoveryNegativeSignalController extends Controller
{
public function hideArtwork(Request $request): JsonResponse
{
$payload = $request->validate([
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'source' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$signal = UserNegativeSignal::query()->updateOrCreate(
[
'user_id' => (int) $request->user()->id,
'signal_type' => 'hide_artwork',
'artwork_id' => (int) $payload['artwork_id'],
],
[
'tag_id' => null,
'algo_version' => $payload['algo_version'] ?? null,
'source' => $payload['source'] ?? 'api',
'meta' => (array) ($payload['meta'] ?? []),
]
);
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: (int) $payload['artwork_id'],
eventType: 'hide_artwork',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: (array) ($payload['meta'] ?? [])
);
return response()->json([
'stored' => true,
'signal_id' => (int) $signal->id,
'signal_type' => 'hide_artwork',
], Response::HTTP_ACCEPTED);
}
public function dislikeTag(Request $request): JsonResponse
{
$payload = $request->validate([
'tag_id' => ['nullable', 'integer', 'exists:tags,id'],
'tag_slug' => ['nullable', 'string', 'max:191'],
'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'source' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null;
if ($tagId === null && ! empty($payload['tag_slug'])) {
$tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id');
}
abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.');
$signal = UserNegativeSignal::query()->updateOrCreate(
[
'user_id' => (int) $request->user()->id,
'signal_type' => 'dislike_tag',
'tag_id' => $tagId,
],
[
'artwork_id' => null,
'algo_version' => $payload['algo_version'] ?? null,
'source' => $payload['source'] ?? 'api',
'meta' => (array) ($payload['meta'] ?? []),
]
);
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)),
eventType: 'dislike_tag',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId])
);
return response()->json([
'stored' => true,
'signal_id' => (int) $signal->id,
'signal_type' => 'dislike_tag',
'tag_id' => $tagId,
], Response::HTTP_ACCEPTED);
}
public function unhideArtwork(Request $request): JsonResponse
{
$payload = $request->validate([
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$deleted = UserNegativeSignal::query()
->where('user_id', (int) $request->user()->id)
->where('signal_type', 'hide_artwork')
->where('artwork_id', (int) $payload['artwork_id'])
->delete();
if ($deleted > 0) {
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: (int) $payload['artwork_id'],
eventType: 'unhide_artwork',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: (array) ($payload['meta'] ?? [])
);
}
return response()->json([
'revoked' => $deleted > 0,
'signal_type' => 'hide_artwork',
'artwork_id' => (int) $payload['artwork_id'],
], Response::HTTP_OK);
}
public function undislikeTag(Request $request): JsonResponse
{
$payload = $request->validate([
'tag_id' => ['nullable', 'integer', 'exists:tags,id'],
'tag_slug' => ['nullable', 'string', 'max:191'],
'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null;
if ($tagId === null && ! empty($payload['tag_slug'])) {
$tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id');
}
abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.');
$deleted = UserNegativeSignal::query()
->where('user_id', (int) $request->user()->id)
->where('signal_type', 'dislike_tag')
->where('tag_id', $tagId)
->delete();
if ($deleted > 0) {
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)),
eventType: 'undo_dislike_tag',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId])
);
}
return response()->json([
'revoked' => $deleted > 0,
'signal_type' => 'dislike_tag',
'tag_id' => $tagId,
], Response::HTTP_OK);
}
/**
* @param array<string, mixed> $meta
*/
private function recordFeedbackEvent(int $userId, int $artworkId, string $eventType, ?string $algoVersion = null, array $meta = []): void
{
if ($artworkId <= 0 || ! Schema::hasTable('user_discovery_events')) {
return;
}
$categoryId = DB::table('artwork_category')
->where('artwork_id', $artworkId)
->orderBy('category_id')
->value('category_id');
DB::table('user_discovery_events')->insert([
'event_id' => (string) Str::uuid(),
'user_id' => $userId,
'artwork_id' => $artworkId,
'category_id' => $categoryId !== null ? (int) $categoryId : null,
'event_type' => $eventType,
'event_version' => (string) config('discovery.event_version', 'event-v1'),
'algo_version' => (string) ($algoVersion ?: config('discovery.v2.algo_version', config('discovery.algo_version', 'clip-cosine-v1'))),
'weight' => 0.0,
'event_date' => now()->toDateString(),
'occurred_at' => now()->toDateTimeString(),
'meta' => json_encode($meta, JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@@ -5,13 +5,13 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Recommendations\PersonalizedFeedService;
use App\Services\Recommendations\RecommendationFeedResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class FeedController extends Controller
{
public function __construct(private readonly PersonalizedFeedService $feedService)
public function __construct(private readonly RecommendationFeedResolver $feedResolver)
{
}
@@ -23,7 +23,7 @@ final class FeedController extends Controller
'algo_version' => ['nullable', 'string', 'max:64'],
]);
$result = $this->feedService->getFeed(
$result = $this->feedResolver->getFeed(
userId: (int) $request->user()->id,
limit: isset($payload['limit']) ? (int) $payload['limit'] : 24,
cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null,

View File

@@ -44,6 +44,8 @@ final class FollowController extends Controller
return response()->json([
'following' => true,
'followers_count' => $this->followService->followersCount((int) $target->id),
'following_count' => $this->followService->followingCount((int) $actor->id),
'context' => $this->followService->relationshipContext((int) $actor->id, (int) $target->id),
]);
}
@@ -59,6 +61,8 @@ final class FollowController extends Controller
return response()->json([
'following' => false,
'followers_count' => $this->followService->followersCount((int) $target->id),
'following_count' => $this->followService->followingCount((int) $actor->id),
'context' => $this->followService->relationshipContext((int) $actor->id, (int) $target->id),
]);
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Vision\VectorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
final class ImageSearchController extends Controller
{
public function __construct(private readonly VectorService $vectors)
{
}
public function __invoke(Request $request): JsonResponse
{
$payload = $request->validate([
'image' => ['required', 'file', 'image', 'max:10240'],
'limit' => ['nullable', 'integer', 'min:1', 'max:24'],
]);
if (! $this->vectors->isConfigured()) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_not_configured',
], 503);
}
$limit = (int) ($payload['limit'] ?? 12);
try {
$items = $this->vectors->searchByUploadedImage($payload['image'], $limit);
} catch (RuntimeException $e) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_error',
'message' => $e->getMessage(),
], 502);
}
return response()->json([
'data' => $items,
'meta' => [
'source' => 'vector_gateway',
'limit' => $limit,
],
]);
}
}

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

@@ -2,12 +2,18 @@
namespace App\Http\Controllers\Api\Messaging;
use App\Events\ConversationUpdated;
use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\ManageConversationParticipantRequest;
use App\Http\Requests\Messaging\RenameConversationRequest;
use App\Http\Requests\Messaging\StoreConversationRequest;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use App\Services\Messaging\MessageNotificationService;
use App\Services\Messaging\ConversationReadService;
use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\SendMessageAction;
use App\Services\Messaging\UnreadCounterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -16,6 +22,13 @@ use Illuminate\Support\Facades\Schema;
class ConversationController extends Controller
{
public function __construct(
private readonly ConversationStateService $conversationState,
private readonly ConversationReadService $conversationReads,
private readonly SendMessageAction $sendMessage,
private readonly UnreadCounterService $unreadCounters,
) {}
// ── GET /api/messages/conversations ─────────────────────────────────────
public function index(Request $request): JsonResponse
@@ -26,24 +39,14 @@ class ConversationController extends Controller
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
return Conversation::query()
$query = 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');
}),
])
->where('conversations.is_active', true)
->with([
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
'latestMessage.sender:id,username',
@@ -51,8 +54,11 @@ class ConversationController extends Controller
->orderByDesc('cp_me.is_pinned')
->orderByDesc('cp_me.pinned_at')
->orderByDesc('last_message_at')
->orderByDesc('conversations.id')
->paginate(20, ['conversations.*'], 'page', $page);
->orderByDesc('conversations.id');
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
return $query->paginate(20, ['conversations.*'], 'page', $page);
});
$conversations->through(function ($conv) use ($user) {
@@ -61,7 +67,12 @@ class ConversationController extends Controller
return $conv;
});
return response()->json($conversations);
return response()->json([
...$conversations->toArray(),
'summary' => [
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
],
]);
}
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
@@ -80,18 +91,10 @@ class ConversationController extends Controller
// ── POST /api/messages/conversation ─────────────────────────────────────
public function store(Request $request): JsonResponse
public function store(StoreConversationRequest $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',
]);
$data = $request->validated();
if ($data['type'] === 'direct') {
return $this->createDirect($request, $user, $data);
@@ -104,20 +107,29 @@ class ConversationController extends Controller
public function markRead(Request $request, int $id): JsonResponse
{
$participant = $this->participantRecord($request, $id);
$participant->update(['last_read_at' => now()]);
$this->touchConversationCachesForUsers([$request->user()->id]);
$conversation = $this->findAuthorized($request, $id);
$participant = $this->conversationReads->markConversationRead(
$conversation,
$request->user(),
$request->integer('message_id') ?: null,
);
return response()->json(['ok' => true]);
return response()->json([
'ok' => true,
'last_read_at' => optional($participant->last_read_at)?->toIso8601String(),
'last_read_message_id' => $participant->last_read_message_id,
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
]);
}
// ── POST /api/messages/{conversation_id}/archive ─────────────────────────
public function archive(Request $request, int $id): JsonResponse
{
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participant->update(['is_archived' => ! $participant->is_archived]);
$this->touchConversationCachesForUsers([$request->user()->id]);
$this->broadcastConversationUpdate($conversation, 'conversation.archived');
return response()->json(['is_archived' => $participant->is_archived]);
}
@@ -126,27 +138,30 @@ class ConversationController extends Controller
public function mute(Request $request, int $id): JsonResponse
{
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participant->update(['is_muted' => ! $participant->is_muted]);
$this->touchConversationCachesForUsers([$request->user()->id]);
$this->broadcastConversationUpdate($conversation, 'conversation.muted');
return response()->json(['is_muted' => $participant->is_muted]);
}
public function pin(Request $request, int $id): JsonResponse
{
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
$this->touchConversationCachesForUsers([$request->user()->id]);
$this->broadcastConversationUpdate($conversation, 'conversation.pinned');
return response()->json(['is_pinned' => true]);
}
public function unpin(Request $request, int $id): JsonResponse
{
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
$this->touchConversationCachesForUsers([$request->user()->id]);
$this->broadcastConversationUpdate($conversation, 'conversation.unpinned');
return response()->json(['is_pinned' => false]);
}
@@ -182,14 +197,15 @@ class ConversationController extends Controller
}
$participant->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.left', $participantUserIds);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/add-user ────────────────────────
public function addUser(Request $request, int $id): JsonResponse
public function addUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
$this->requireAdmin($request, $id);
@@ -198,9 +214,7 @@ class ConversationController extends Controller
->pluck('user_id')
->all();
$data = $request->validate([
'user_id' => 'required|integer|exists:users,id',
]);
$data = $request->validated();
$existing = ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id'])
@@ -220,20 +234,18 @@ class ConversationController extends Controller
}
$participantUserIds[] = (int) $data['user_id'];
$this->touchConversationCachesForUsers($participantUserIds);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.participant_added', $participantUserIds);
return response()->json(['ok' => true]);
}
// ── DELETE /api/messages/{conversation_id}/remove-user ───────────────────
public function removeUser(Request $request, int $id): JsonResponse
public function removeUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
{
$this->requireAdmin($request, $id);
$data = $request->validate([
'user_id' => 'required|integer',
]);
$data = $request->validated();
// Cannot remove the conversation creator
$conv = Conversation::findOrFail($id);
@@ -263,26 +275,28 @@ class ConversationController extends Controller
->whereNull('left_at')
->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.participant_removed', $participantUserIds);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/rename ──────────────────────────
public function rename(Request $request, int $id): JsonResponse
public function rename(RenameConversationRequest $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']);
$data = $request->validated();
$conv->update(['title' => $data['title']]);
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds);
return response()->json(['title' => $conv->title]);
}
@@ -307,8 +321,10 @@ class ConversationController extends Controller
if (! $conv) {
$conv = DB::transaction(function () use ($user, $recipient) {
$conv = Conversation::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'type' => 'direct',
'created_by' => $user->id,
'is_active' => true,
]);
ConversationParticipant::insert([
@@ -320,17 +336,12 @@ class ConversationController extends Controller
});
}
// Insert first / next message
$message = $conv->messages()->create([
'sender_id' => $user->id,
'body' => $data['body'],
$this->sendMessage->execute($conv, $user, [
'body' => $data['body'],
'client_temp_id' => $data['client_temp_id'] ?? null,
]);
$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);
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
}
private function createGroup(Request $request, User $user, array $data): JsonResponse
@@ -339,9 +350,11 @@ class ConversationController extends Controller
$conv = DB::transaction(function () use ($user, $data, $participantIds) {
$conv = Conversation::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'type' => 'group',
'title' => $data['title'],
'created_by' => $user->id,
'is_active' => true,
]);
$rows = array_map(fn ($uid) => [
@@ -353,27 +366,21 @@ class ConversationController extends Controller
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];
return $conv;
});
[$conversation, $message] = $conv;
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user);
$this->touchConversationCachesForUsers($participantIds);
$this->sendMessage->execute($conv, $user, [
'body' => $data['body'],
'client_temp_id' => $data['client_temp_id'] ?? null,
]);
return response()->json($conversation->load('allParticipants.user:id,username'), 201);
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
}
private function findAuthorized(Request $request, int $id): Conversation
{
$conv = Conversation::findOrFail($id);
$this->assertParticipant($request, $id);
$this->authorize('view', $conv);
return $conv;
}
@@ -399,28 +406,13 @@ class ConversationController extends Controller
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.'
);
$conversation = Conversation::findOrFail($id);
$this->authorize('manageParticipants', $conversation);
}
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);
}
$this->conversationState->touchConversationCachesForUsers($userIds);
}
private function cacheVersionKey(int $userId): string
@@ -433,6 +425,16 @@ class ConversationController extends Controller
return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}";
}
private function broadcastConversationUpdate(Conversation $conversation, string $reason, ?array $participantIds = null): void
{
$participantIds ??= $this->conversationState->activeParticipantIds($conversation);
$this->conversationState->touchConversationCachesForUsers($participantIds);
foreach ($participantIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, $reason));
}
}
private function assertNotBlockedBetween(User $sender, User $recipient): void
{
if (! Schema::hasTable('user_blocks')) {

View File

@@ -2,31 +2,55 @@
namespace App\Http\Controllers\Api\Messaging;
use App\Events\MessageSent;
use App\Events\ConversationUpdated;
use App\Events\MessageDeleted;
use App\Events\MessageUpdated;
use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\StoreMessageRequest;
use App\Http\Requests\Messaging\ToggleMessageReactionRequest;
use App\Http\Requests\Messaging\UpdateMessageRequest;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\MessageReaction;
use App\Services\Messaging\ConversationDeltaService;
use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\MessagingPayloadFactory;
use App\Services\Messaging\MessageSearchIndexer;
use App\Services\Messaging\MessageNotificationService;
use App\Services\Messaging\SendMessageAction;
use App\Services\Messaging\UnreadCounterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
class MessageController extends Controller
{
private const PAGE_SIZE = 30;
public function __construct(
private readonly ConversationDeltaService $conversationDelta,
private readonly ConversationStateService $conversationState,
private readonly MessagingPayloadFactory $payloadFactory,
private readonly SendMessageAction $sendMessage,
private readonly UnreadCounterService $unreadCounters,
) {}
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
public function index(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$cursor = $request->integer('cursor');
$conversation = $this->findConversationOrFail($conversationId);
$cursor = $request->integer('cursor') ?: $request->integer('before_id');
$afterId = $request->integer('after_id');
if ($afterId) {
$messages = $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterId);
return response()->json([
'data' => $messages,
'next_cursor' => null,
]);
}
$query = Message::withTrashed()
->where('conversation_id', $conversationId)
@@ -44,65 +68,49 @@ class MessageController extends Controller
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
return response()->json([
'data' => $messages,
'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(),
'next_cursor' => $nextCursor,
]);
}
public function delta(Request $request, int $conversationId): JsonResponse
{
$conversation = $this->findConversationOrFail($conversationId);
$afterMessageId = max(0, (int) $request->integer('after_message_id'));
abort_if($afterMessageId < 1, 422, 'after_message_id is required.');
return response()->json([
'data' => $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterMessageId),
'conversation' => $this->payloadFactory->conversationSummary($conversation->fresh(), (int) $request->user()->id),
'summary' => [
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
],
]);
}
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
public function store(Request $request, int $conversationId): JsonResponse
public function store(StoreMessageRequest $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',
]);
$conversation = $this->findConversationOrFail($conversationId);
$data = $request->validated();
$data['attachments'] = $request->file('attachments', []);
$body = trim((string) ($data['body'] ?? ''));
$files = $request->file('attachments', []);
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.');
$message = Message::create([
'conversation_id' => $conversationId,
'sender_id' => $request->user()->id,
'body' => $body,
]);
$message = $this->sendMessage->execute($conversation, $request->user(), $data);
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);
return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201);
}
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
public function react(Request $request, int $conversationId, int $messageId): JsonResponse
public function react(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->findConversationOrFail($conversationId);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([
@@ -126,11 +134,10 @@ class MessageController extends Controller
// ── DELETE /api/messages/{conversation_id}/react ─────────────────────────
public function unreact(Request $request, int $conversationId, int $messageId): JsonResponse
public function unreact(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->findConversationOrFail($conversationId);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
MessageReaction::where([
@@ -142,12 +149,11 @@ class MessageController extends Controller
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
public function reactByMessage(Request $request, int $messageId): JsonResponse
public function reactByMessage(ToggleMessageReactionRequest $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->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([
@@ -169,12 +175,11 @@ class MessageController extends Controller
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
public function unreactByMessage(Request $request, int $messageId): JsonResponse
public function unreactByMessage(ToggleMessageReactionRequest $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->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
MessageReaction::where([
@@ -188,19 +193,15 @@ class MessageController extends Controller
// ── PATCH /api/messages/message/{messageId} ───────────────────────────────
public function update(Request $request, int $messageId): JsonResponse
public function update(UpdateMessageRequest $request, int $messageId): JsonResponse
{
$message = Message::findOrFail($messageId);
abort_unless(
$message->sender_id === $request->user()->id,
403,
'You may only edit your own messages.'
);
$this->authorize('update', $message);
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
$data = $request->validate(['body' => 'required|string|max:5000']);
$data = $request->validated();
$message->update([
'body' => $data['body'],
@@ -208,13 +209,21 @@ class MessageController extends Controller
]);
app(MessageSearchIndexer::class)->updateMessage($message);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
return response()->json($message->fresh());
DB::afterCommit(function () use ($message, $participantUserIds): void {
event(new MessageUpdated($message->fresh(['sender:id,username,name', 'attachments', 'reactions'])));
$conversation = Conversation::find($message->conversation_id);
if ($conversation) {
foreach ($participantUserIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, 'message.updated'));
}
}
});
return response()->json($this->payloadFactory->message($message->fresh(['sender:id,username,name', 'attachments', 'reactions']), (int) $request->user()->id));
}
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
@@ -223,19 +232,24 @@ class MessageController extends Controller
{
$message = Message::findOrFail($messageId);
abort_unless(
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
403,
'You may only delete your own messages.'
);
$this->authorize('delete', $message);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
->whereNull('left_at')
->pluck('user_id')
->all();
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
app(MessageSearchIndexer::class)->deleteMessage($message);
$message->delete();
$this->touchConversationCachesForUsers($participantUserIds);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
DB::afterCommit(function () use ($message, $participantUserIds): void {
$message->refresh();
event(new MessageDeleted($message));
$conversation = Conversation::find($message->conversation_id);
if ($conversation) {
foreach ($participantUserIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, 'message.deleted'));
}
}
});
return response()->json(['ok' => true]);
}
@@ -256,15 +270,7 @@ class MessageController extends Controller
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);
}
$this->conversationState->touchConversationCachesForUsers($userIds);
}
private function assertAllowedReaction(string $reaction): void
@@ -298,54 +304,11 @@ class MessageController extends Controller
return $summary;
}
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void
private function findConversationOrFail(int $conversationId): Conversation
{
$mime = (string) $file->getMimeType();
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname());
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
$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(),
]);
return $conversation;
}
}

View File

@@ -71,18 +71,12 @@ class MessageSearchController extends Controller
$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]);
if ($hits->isEmpty()) {
[$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
}
} catch (\Throwable) {
[$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
}
$messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all();
@@ -122,6 +116,23 @@ class MessageSearchController extends Controller
]);
}
private function fallbackHits(array $allowedConversationIds, ?int $conversationId, string $queryString, int $offset, int $limit): array
{
$query = Message::query()
->select('id')
->whereNull('deleted_at')
->whereIn('conversation_id', $allowedConversationIds)
->when($conversationId !== null, fn ($builder) => $builder->where('conversation_id', $conversationId))
->where('body', 'like', '%' . $queryString . '%')
->orderByDesc('created_at')
->orderByDesc('id');
$estimated = (clone $query)->count();
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
return [$hits, $estimated];
}
public function rebuild(Request $request): JsonResponse
{
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');

View File

@@ -16,9 +16,13 @@ class MessagingSettingsController extends Controller
{
public function show(Request $request): JsonResponse
{
$realtimeReady = (bool) config('messaging.realtime', false)
&& config('broadcasting.default') === 'reverb'
&& filled(config('broadcasting.connections.reverb.key'));
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
'realtime_enabled' => (bool) config('messaging.realtime', false),
'realtime_enabled' => $realtimeReady,
]);
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Services\Messaging\MessagingPresenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PresenceController extends Controller
{
public function __construct(
private readonly MessagingPresenceService $presence,
) {}
public function heartbeat(Request $request): JsonResponse
{
$conversationId = $request->integer('conversation_id') ?: null;
if ($conversationId) {
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
}
$this->presence->touch($request->user(), $conversationId);
return response()->json([
'ok' => true,
'conversation_id' => $conversationId,
]);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Messaging;
use App\Events\TypingStarted;
use App\Events\TypingStopped;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use Illuminate\Cache\Repository;
use Illuminate\Http\JsonResponse;
@@ -15,13 +16,13 @@ class TypingController extends Controller
{
public function start(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$this->findConversationOrFail($conversationId);
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
if ((bool) config('messaging.realtime', false)) {
event(new TypingStarted($conversationId, (int) $request->user()->id));
event(new TypingStarted($conversationId, $request->user()));
}
return response()->json(['ok' => true]);
@@ -29,11 +30,11 @@ class TypingController extends Controller
public function stop(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$this->findConversationOrFail($conversationId);
$this->store()->forget($this->key($conversationId, (int) $request->user()->id));
if ((bool) config('messaging.realtime', false)) {
event(new TypingStopped($conversationId, (int) $request->user()->id));
event(new TypingStopped($conversationId, $request->user()));
}
return response()->json(['ok' => true]);
@@ -41,7 +42,7 @@ class TypingController extends Controller
public function index(Request $request, int $conversationId): JsonResponse
{
$this->assertParticipant($request, $conversationId);
$this->findConversationOrFail($conversationId);
$userId = (int) $request->user()->id;
$participants = ConversationParticipant::query()
@@ -93,4 +94,12 @@ class TypingController extends Controller
return Cache::store();
}
}
private function findConversationOrFail(int $conversationId): Conversation
{
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
return $conversation;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Api;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
/**
* GET /api/notifications digestd notification list
* POST /api/notifications/read-all mark all unread as read
* POST /api/notifications/{id}/read mark single as read
*/
class NotificationController extends Controller
{
public function __construct(private NotificationService $notifications) {}
public function index(Request $request): JsonResponse
{
return response()->json(
$this->notifications->listForUser($request->user(), (int) $request->query('page', 1), 20)
);
}
public function readAll(Request $request): JsonResponse
{
$this->notifications->markAllRead($request->user());
return response()->json(['message' => 'All notifications marked as read.']);
}
public function markRead(Request $request, string $id): JsonResponse
{
$this->notifications->markRead($request->user(), $id);
return response()->json(['message' => 'Notification marked as read.']);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardAiAssistService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardRelatedCardsService;
use App\Services\NovaCards\NovaCardRisingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NovaCardDiscoveryController extends Controller
{
public function __construct(
private readonly NovaCardPresenter $presenter,
private readonly NovaCardRisingService $rising,
private readonly NovaCardRelatedCardsService $related,
private readonly NovaCardAiAssistService $aiAssist,
) {
}
/**
* GET /api/cards/rising
* Returns recently published cards gaining traction fast.
*/
public function rising(Request $request): JsonResponse
{
$limit = min((int) $request->query('limit', 18), 36);
$cards = $this->rising->risingCards($limit);
return response()->json([
'data' => $this->presenter->cards($cards->all(), false, $request->user()),
]);
}
/**
* GET /api/cards/{id}/related
* Returns related cards for a given card.
*/
public function related(Request $request, int $id): JsonResponse
{
$card = NovaCard::query()->publiclyVisible()->findOrFail($id);
$limit = min((int) $request->query('limit', 8), 16);
$relatedCards = $this->related->related($card, $limit);
return response()->json([
'data' => $this->presenter->cards($relatedCards->all(), false, $request->user()),
]);
}
/**
* GET /api/cards/{id}/ai-suggest
* Returns AI-assist suggestions for the given draft card.
* The creator must own the card.
*/
public function suggest(Request $request, int $id): JsonResponse
{
$card = NovaCard::query()
->where('user_id', $request->user()->id)
->findOrFail($id);
$suggestions = $this->aiAssist->allSuggestions($card);
return response()->json([
'data' => $suggestions,
]);
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Events\NovaCards\NovaCardBackgroundUploaded;
use App\Events\NovaCards\NovaCardPublished;
use App\Http\Controllers\Controller;
use App\Http\Requests\NovaCards\SaveNovaCardDraftRequest;
use App\Http\Requests\NovaCards\StoreNovaCardDraftRequest;
use App\Http\Requests\NovaCards\UploadNovaCardBackgroundRequest;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardBackgroundService;
use App\Services\NovaCards\NovaCardDraftService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardPublishService;
use App\Services\NovaCards\NovaCardRenderService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NovaCardDraftController extends Controller
{
public function __construct(
private readonly NovaCardDraftService $drafts,
private readonly NovaCardBackgroundService $backgrounds,
private readonly NovaCardRenderService $renders,
private readonly NovaCardPublishService $publishes,
private readonly NovaCardPresenter $presenter,
) {
}
public function store(StoreNovaCardDraftRequest $request): JsonResponse
{
$card = $this->drafts->createDraft($request->user(), $request->validated());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
], Response::HTTP_CREATED);
}
public function show(Request $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function update(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$card = $this->drafts->autosave($card, $request->validated());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function autosave(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$card = $this->drafts->autosave($card, $request->validated());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
'meta' => [
'saved_at' => now()->toISOString(),
],
]);
}
public function background(UploadNovaCardBackgroundRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$background = $this->backgrounds->storeUploadedBackground($request->user(), $request->file('background'));
$card = $this->drafts->autosave($card, [
'background_type' => 'upload',
'background_image_id' => $background->id,
'project_json' => [
'background' => [
'type' => 'upload',
'background_image_id' => $background->id,
],
],
]);
event(new NovaCardBackgroundUploaded($card, $background));
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
'background' => [
'id' => (int) $background->id,
'processed_url' => $background->processedUrl(),
'width' => (int) $background->width,
'height' => (int) $background->height,
],
], Response::HTTP_CREATED);
}
public function render(Request $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$result = $this->renders->render($card->loadMissing('backgroundImage'));
return response()->json([
'data' => $this->presenter->card($card->fresh()->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags']), true, $request->user()),
'render' => $result,
]);
}
public function publish(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
if ($request->validated() !== []) {
$card = $this->drafts->autosave($card, $request->validated());
}
if (trim((string) $card->title) === '' || trim((string) $card->quote_text) === '') {
return response()->json([
'message' => 'Title and quote text are required before publishing.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$card = $this->publishes->publishNow($card->loadMissing('backgroundImage'));
event(new NovaCardPublished($card));
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
if ($card->status === NovaCard::STATUS_PUBLISHED && in_array($card->visibility, [NovaCard::VISIBILITY_PUBLIC, NovaCard::VISIBILITY_UNLISTED], true)) {
return response()->json([
'message' => 'Published cards cannot be deleted from the draft API.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$card->delete();
return response()->json([
'ok' => true,
]);
}
private function editableCard(Request $request, int $id): NovaCard
{
return NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
->where('user_id', $request->user()->id)
->findOrFail($id);
}
}

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