Compare commits
29 Commits
90f244f264
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| cab4fbd83e | |||
| 0b25d9570a | |||
| 73260e7eae | |||
| 2608be7420 | |||
| e8b5edf5d2 | |||
| 60f78e8235 | |||
| 979e011257 | |||
| 29c3ff8572 | |||
| 1a62fcb81d | |||
| 7da0fd39f7 | |||
| 7b37259a2c | |||
| 2119741ba7 | |||
| 2728644477 | |||
| b3fc889452 | |||
| 980a15f66e | |||
| 78151aabfe | |||
| 4f576ceb04 | |||
| 547215cbe8 | |||
| 23b813bbff | |||
| f6772f673b | |||
| 5a33ca55a1 | |||
| b9c2d8597d | |||
| dc51d65440 | |||
| 1266f81d35 | |||
| a875203482 | |||
| e3ca845a6d | |||
| 211dc58884 | |||
| 916bb29a53 | |||
| de3ec22ee5 |
8
.env.cpad
Normal file
8
.env.cpad
Normal file
@@ -0,0 +1,8 @@
|
||||
# cPad Configuration
|
||||
# Template: custom
|
||||
|
||||
CPAD_DEBUG=false
|
||||
CPAD_CACHE_ENABLED=true
|
||||
CPAD_LOG_LEVEL=WARNING
|
||||
CPAD_SECURITY_LEVEL=MAXIMUM
|
||||
CPAD_BACKUP_ENABLED=true
|
||||
120
.env.example
120
.env.example
@@ -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
28
.gitignore
vendored
@@ -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/*
|
||||
|
||||
15
README.md
15
README.md
@@ -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
8
TODO.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# TODO SKINBASE NOVA
|
||||
|
||||
## FORUM
|
||||
|
||||
- [ ] we need to add in a main search (toolbar) and a search in the forum (search bar in the forum page)
|
||||
|
||||
## ARTWORKS
|
||||
- [ ] http://skinbase26.test/art/69601/testna-slika => we shouldnt display follow for yourself
|
||||
@@ -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>';
|
||||
|
||||
103
app/Console/Commands/AggregateDiscoveryFeedbackCommand.php
Normal file
103
app/Console/Commands/AggregateDiscoveryFeedbackCommand.php
Normal 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')";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -48,20 +48,72 @@ final class AiTagArtworksCommand extends Command
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
You are an expert at analysing visual artwork and generating concise, descriptive tags.
|
||||
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.
|
||||
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 1–4 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;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
519
app/Console/Commands/AuditMigrationSchemaCommand.php
Normal file
519
app/Console/Commands/AuditMigrationSchemaCommand.php
Normal file
@@ -0,0 +1,519 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
class AuditMigrationSchemaCommand extends Command
|
||||
{
|
||||
protected $signature = 'schema:audit-migrations
|
||||
{--all-files : Audit all discovered migration files, not only migrations marked as ran}
|
||||
{--json : Output the report as JSON}
|
||||
{--base-path=* : Additional base paths to scan for migrations, relative to project root}';
|
||||
|
||||
protected $description = 'Compare the live database schema against executed migration files and report missing tables or columns';
|
||||
|
||||
private const NO_ARG_COLUMN_METHODS = [
|
||||
'id' => ['id'],
|
||||
'timestamps' => ['created_at', 'updated_at'],
|
||||
'timestampsTz' => ['created_at', 'updated_at'],
|
||||
'softDeletes' => ['deleted_at'],
|
||||
'softDeletesTz' => ['deleted_at'],
|
||||
'rememberToken' => ['remember_token'],
|
||||
];
|
||||
|
||||
private const NON_COLUMN_METHODS = [
|
||||
'index',
|
||||
'unique',
|
||||
'primary',
|
||||
'foreign',
|
||||
'foreignIdFor',
|
||||
'dropColumn',
|
||||
'dropColumns',
|
||||
'dropIndex',
|
||||
'dropUnique',
|
||||
'dropPrimary',
|
||||
'dropForeign',
|
||||
'dropConstrainedForeignId',
|
||||
'renameColumn',
|
||||
'renameIndex',
|
||||
'constrained',
|
||||
'cascadeOnDelete',
|
||||
'restrictOnDelete',
|
||||
'nullOnDelete',
|
||||
'cascadeOnUpdate',
|
||||
'restrictOnUpdate',
|
||||
'nullOnUpdate',
|
||||
'after',
|
||||
'nullable',
|
||||
'default',
|
||||
'useCurrent',
|
||||
'useCurrentOnUpdate',
|
||||
'comment',
|
||||
'charset',
|
||||
'collation',
|
||||
'storedAs',
|
||||
'virtualAs',
|
||||
'generatedAs',
|
||||
'always',
|
||||
'invisible',
|
||||
'first',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$migrationFiles = $this->discoverMigrationFiles();
|
||||
$ranMigrations = collect(DB::table('migrations')->pluck('migration')->all())
|
||||
->mapWithKeys(fn (string $migration): array => [$migration => true])
|
||||
->all();
|
||||
|
||||
$expected = [];
|
||||
$parsedFiles = 0;
|
||||
|
||||
foreach ($migrationFiles as $migrationName => $path) {
|
||||
if (! $this->option('all-files') && ! isset($ranMigrations[$migrationName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedFiles++;
|
||||
$operations = $this->parseMigrationFile($path);
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
$table = $operation['table'];
|
||||
|
||||
if ($operation['type'] === 'create-table' && isset($expected[$table])) {
|
||||
$expected[$table]['sources'][$migrationName] = true;
|
||||
|
||||
if (Schema::hasTable($table)) {
|
||||
$actualColumns = array_fill_keys(
|
||||
array_map('strtolower', Schema::getColumnListing($table)),
|
||||
true
|
||||
);
|
||||
|
||||
$existingColumns = array_fill_keys(array_keys($expected[$table]['columns']), true);
|
||||
$replacementColumns = [];
|
||||
|
||||
foreach ($operation['add'] as $column) {
|
||||
if (! isset($existingColumns[$column]) && isset($actualColumns[$column])) {
|
||||
$replacementColumns[$column] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($replacementColumns !== []) {
|
||||
foreach ($replacementColumns as $column => $_) {
|
||||
$expected[$table]['columns'][$column] = true;
|
||||
}
|
||||
|
||||
foreach (array_keys($expected[$table]['columns']) as $column) {
|
||||
if (! isset($actualColumns[$column]) && ! isset($replacementColumns[$column])) {
|
||||
unset($expected[$table]['columns'][$column]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operation['type'] === 'alter-table' && ! isset($expected[$table]) && ! Schema::hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expected[$table] ??= [
|
||||
'columns' => [],
|
||||
'sources' => [],
|
||||
];
|
||||
|
||||
$expected[$table]['sources'][$migrationName] = true;
|
||||
|
||||
if ($operation['type'] === 'drop-table') {
|
||||
unset($expected[$table]);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($operation['add'] as $column) {
|
||||
$expected[$table]['columns'][$column] = true;
|
||||
}
|
||||
|
||||
foreach ($operation['drop'] as $column) {
|
||||
unset($expected[$table]['columns'][$column]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksort($expected);
|
||||
|
||||
$report = [
|
||||
'parsed_files' => $parsedFiles,
|
||||
'expected_tables' => count($expected),
|
||||
'missing_tables' => [],
|
||||
'missing_columns' => [],
|
||||
];
|
||||
|
||||
foreach ($expected as $table => $spec) {
|
||||
$sources = array_keys($spec['sources']);
|
||||
sort($sources);
|
||||
|
||||
if (! Schema::hasTable($table)) {
|
||||
$report['missing_tables'][] = [
|
||||
'table' => $table,
|
||||
'sources' => $sources,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$actualColumns = array_map('strtolower', Schema::getColumnListing($table));
|
||||
$expectedColumns = array_keys($spec['columns']);
|
||||
sort($expectedColumns);
|
||||
|
||||
$missing = array_values(array_diff($expectedColumns, $actualColumns));
|
||||
if ($missing !== []) {
|
||||
$report['missing_columns'][] = [
|
||||
'table' => $table,
|
||||
'columns' => $missing,
|
||||
'sources' => $sources,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ((bool) $this->option('json')) {
|
||||
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
} else {
|
||||
$this->renderReport($report);
|
||||
}
|
||||
|
||||
return ($report['missing_tables'] === [] && $report['missing_columns'] === [])
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function discoverMigrationFiles(): array
|
||||
{
|
||||
$paths = [
|
||||
database_path('migrations'),
|
||||
base_path('packages/klevze'),
|
||||
];
|
||||
|
||||
foreach ((array) $this->option('base-path') as $relativePath) {
|
||||
$resolved = base_path((string) $relativePath);
|
||||
if (is_dir($resolved)) {
|
||||
$paths[] = $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
$finder = new Finder();
|
||||
$finder->files()->name('*.php');
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (is_dir($path)) {
|
||||
$finder->in($path);
|
||||
}
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach ($finder as $file) {
|
||||
$realPath = $file->getRealPath();
|
||||
if (! $realPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = str_replace('\\', '/', $realPath);
|
||||
if (! str_contains($normalized, '/database/migrations/') && ! str_contains($normalized, '/Migrations/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[pathinfo($realPath, PATHINFO_FILENAME)] = $realPath;
|
||||
}
|
||||
|
||||
ksort($files);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{type:string, table:string, add:array<int,string>, drop:array<int,string>}>
|
||||
*/
|
||||
private function parseMigrationFile(string $path): array
|
||||
{
|
||||
$content = File::get($path);
|
||||
$upBody = $this->extractMethodBody($content, 'up');
|
||||
if ($upBody === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$operations = [];
|
||||
|
||||
foreach ($this->extractSchemaClosures($upBody) as $closure) {
|
||||
$operations[] = [
|
||||
'type' => $closure['operation'],
|
||||
'table' => $closure['table'],
|
||||
'add' => $this->extractAddedColumns($closure['body']),
|
||||
'drop' => $this->extractDroppedColumns($closure['body']),
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match_all("/Schema::drop(?:IfExists)?\(\s*['\"]([^'\"]+)['\"]\s*\)/", $upBody, $matches)) {
|
||||
foreach ($matches[1] as $table) {
|
||||
$operations[] = [
|
||||
'type' => 'drop-table',
|
||||
'table' => strtolower((string) $table),
|
||||
'add' => [],
|
||||
'drop' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->extractRawAlterTableChanges($upBody) as $change) {
|
||||
$operations[] = [
|
||||
'type' => 'alter-table',
|
||||
'table' => $change['table'],
|
||||
'add' => [$change['new_column']],
|
||||
'drop' => [$change['old_column']],
|
||||
];
|
||||
}
|
||||
|
||||
return $operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{table:string, old_column:string, new_column:string}>
|
||||
*/
|
||||
private function extractRawAlterTableChanges(string $upBody): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
if (preg_match_all(
|
||||
'/ALTER\s+TABLE\s+[`"]?([^`"\s]+)[`"]?\s+CHANGE(?:\s+COLUMN)?\s+[`"]?([^`"\s]+)[`"]?\s+[`"]?([^`"\s]+)[`"]?/i',
|
||||
$upBody,
|
||||
$matches,
|
||||
PREG_SET_ORDER
|
||||
)) {
|
||||
foreach ($matches as $match) {
|
||||
$oldColumn = strtolower((string) $match[2]);
|
||||
$newColumn = strtolower((string) $match[3]);
|
||||
|
||||
if ($oldColumn === $newColumn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changes[] = [
|
||||
'table' => strtolower((string) $match[1]),
|
||||
'old_column' => $oldColumn,
|
||||
'new_column' => $newColumn,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function extractMethodBody(string $content, string $method): ?string
|
||||
{
|
||||
if (! preg_match('/function\s+' . preg_quote($method, '/') . '\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/m', $content, $match, PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$start = $match[0][1] + strlen($match[0][0]) - 1;
|
||||
$end = $this->findMatchingBrace($content, $start);
|
||||
|
||||
if ($end === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr($content, $start + 1, $end - $start - 1);
|
||||
}
|
||||
|
||||
private function findMatchingBrace(string $content, int $openingBracePos): ?int
|
||||
{
|
||||
$length = strlen($content);
|
||||
$depth = 0;
|
||||
$inSingle = false;
|
||||
$inDouble = false;
|
||||
|
||||
for ($index = $openingBracePos; $index < $length; $index++) {
|
||||
$char = $content[$index];
|
||||
$prev = $index > 0 ? $content[$index - 1] : '';
|
||||
|
||||
if ($char === "'" && ! $inDouble && $prev !== '\\') {
|
||||
$inSingle = ! $inSingle;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"' && ! $inSingle && $prev !== '\\') {
|
||||
$inDouble = ! $inDouble;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inSingle || $inDouble) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '{') {
|
||||
$depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '}') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{operation:string, table:string, body:string}>
|
||||
*/
|
||||
private function extractSchemaClosures(string $upBody): array
|
||||
{
|
||||
preg_match_all('/Schema::(create|table)\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*function/s', $upBody, $matches, PREG_OFFSET_CAPTURE);
|
||||
|
||||
$closures = [];
|
||||
foreach ($matches[0] as $index => $fullMatch) {
|
||||
$offset = (int) $fullMatch[1];
|
||||
$operation = strtolower((string) $matches[1][$index][0]) === 'create' ? 'create-table' : 'alter-table';
|
||||
$table = strtolower((string) $matches[2][$index][0]);
|
||||
|
||||
$bracePos = strpos($upBody, '{', $offset);
|
||||
if ($bracePos === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$closing = $this->findMatchingBrace($upBody, $bracePos);
|
||||
if ($closing === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$closures[] = [
|
||||
'operation' => $operation,
|
||||
'table' => $table,
|
||||
'body' => substr($upBody, $bracePos + 1, $closing - $bracePos - 1),
|
||||
];
|
||||
}
|
||||
|
||||
return $closures;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractAddedColumns(string $body): array
|
||||
{
|
||||
$columns = [];
|
||||
|
||||
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$method = (string) $match[1];
|
||||
$column = strtolower((string) $match[2]);
|
||||
|
||||
if (in_array($method, self::NON_COLUMN_METHODS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$columns[$column] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*\)(?:[^;]*)?;/s', $body, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$method = (string) $match[1];
|
||||
foreach (self::NO_ARG_COLUMN_METHODS[$method] ?? [] as $column) {
|
||||
$columns[$column] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('/\$table->(nullableMorphs|morphs|uuidMorphs|nullableUuidMorphs|ulidMorphs|nullableUlidMorphs)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$prefix = strtolower((string) $match[2]);
|
||||
$columns[$prefix . '_type'] = true;
|
||||
$columns[$prefix . '_id'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($columns);
|
||||
|
||||
return array_keys($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractDroppedColumns(string $body): array
|
||||
{
|
||||
$columns = [];
|
||||
|
||||
if (preg_match_all('/\$table->dropColumn\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches)) {
|
||||
foreach ($matches[1] as $column) {
|
||||
$columns[strtolower((string) $column)] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('/\$table->dropColumn\(\s*\[(.*?)\]\s*\);/s', $body, $matches)) {
|
||||
foreach ($matches[1] as $arrayBody) {
|
||||
if (preg_match_all('/[\'\"]([^\'\"]+)[\'\"]/', $arrayBody, $columnMatches)) {
|
||||
foreach ($columnMatches[1] as $column) {
|
||||
$columns[strtolower((string) $column)] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('/\$table->renameColumn\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$columns[strtolower((string) $match[1])] = true;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($columns);
|
||||
|
||||
return array_keys($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{parsed_files:int, expected_tables:int, missing_tables:array<int,array{table:string,sources:array<int,string>}>, missing_columns:array<int,array{table:string,columns:array<int,string>,sources:array<int,string>}>} $report
|
||||
*/
|
||||
private function renderReport(array $report): void
|
||||
{
|
||||
$this->info(sprintf(
|
||||
'Parsed %d migration file(s). Expected schema covers %d table(s).',
|
||||
$report['parsed_files'],
|
||||
$report['expected_tables']
|
||||
));
|
||||
|
||||
if ($report['missing_tables'] === [] && $report['missing_columns'] === []) {
|
||||
$this->info('Schema audit passed. No missing tables or columns detected.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($report['missing_tables'] !== []) {
|
||||
$this->newLine();
|
||||
$this->error('Missing tables:');
|
||||
foreach ($report['missing_tables'] as $item) {
|
||||
$this->line(sprintf(' - %s', $item['table']));
|
||||
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
|
||||
}
|
||||
}
|
||||
|
||||
if ($report['missing_columns'] !== []) {
|
||||
$this->newLine();
|
||||
$this->error('Missing columns:');
|
||||
foreach ($report['missing_columns'] as $item) {
|
||||
$this->line(sprintf(' - %s: %s', $item['table'], implode(', ', $item['columns'])));
|
||||
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal file
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AvatarsBulkUpdate extends Command
|
||||
{
|
||||
protected $signature = 'avatars:bulk-update
|
||||
{path=./user_profiles_avatar.csv : CSV file path (user_id,avatar_hash)}
|
||||
{--dry-run : Do not write to database}
|
||||
';
|
||||
|
||||
protected $description = 'Bulk update user_profiles.avatar_hash from CSV (user_id,avatar_hash)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$path = $this->argument('path');
|
||||
$dry = $this->option('dry-run');
|
||||
|
||||
if (!file_exists($path)) {
|
||||
$this->error("CSV file not found: {$path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info('Reading CSV: ' . $path);
|
||||
|
||||
if (($handle = fopen($path, 'r')) === false) {
|
||||
$this->error('Unable to open CSV file');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$row = 0;
|
||||
$updates = 0;
|
||||
|
||||
while (($data = fgetcsv($handle)) !== false) {
|
||||
$row++;
|
||||
// Skip empty rows
|
||||
if (count($data) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expect at least two columns: user_id, avatar_hash
|
||||
$userId = isset($data[0]) ? trim($data[0]) : null;
|
||||
$hash = isset($data[1]) ? trim($data[1]) : null;
|
||||
|
||||
// If first row looks like a header, skip it
|
||||
if ($row === 1 && (!is_numeric($userId) || $userId === 'user_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($userId === '' || $hash === '') {
|
||||
$this->line("[skip] row={$row} invalid data");
|
||||
continue;
|
||||
}
|
||||
|
||||
$userId = (int) $userId;
|
||||
|
||||
if ($dry) {
|
||||
$this->line("[dry] user={$userId} would set avatar_hash={$hash}");
|
||||
$updates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$affected = DB::table('user_profiles')
|
||||
->where('user_id', $userId)
|
||||
->update([ 'avatar_hash' => $hash, 'avatar_updated_at' => now() ]);
|
||||
|
||||
if ($affected) {
|
||||
$this->line("[ok] user={$userId} avatar_hash updated");
|
||||
$updates++;
|
||||
} else {
|
||||
$this->line("[noop] user={$userId} no row updated (missing profile?)");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("[error] user={$userId} {$e->getMessage()}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
$this->info("Done. Processed rows={$row} updates={$updates}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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);
|
||||
|
||||
if ($dry) {
|
||||
$this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
|
||||
} else {
|
||||
// Use hash-based directory structure: avatars/ab/cd/{hash}/
|
||||
// Precompute storage dir for dry-run and real run
|
||||
$hashPrefix1 = substr($hash, 0, 2);
|
||||
$hashPrefix2 = substr($hash, 2, 2);
|
||||
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
|
||||
Storage::disk('public')->makeDirectory($dir);
|
||||
|
||||
// CDN base for public URLs
|
||||
$cdnBase = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
|
||||
|
||||
if ($dry) {
|
||||
$absPathDry = Storage::disk('public')->path("{$dir}/original.webp");
|
||||
$publicUrlDry = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
|
||||
$this->line("[dry] user={$user->id} would write avatars for hash={$hash} path={$absPathDry} url={$publicUrlDry}");
|
||||
} else {
|
||||
|
||||
// Save original.webp
|
||||
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
29
app/Console/Commands/BackfillArtworkVectorIndexCommand.php
Normal file
29
app/Console/Commands/BackfillArtworkVectorIndexCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
571
app/Console/Commands/BackfillUserActivitiesCommand.php
Normal file
571
app/Console/Commands/BackfillUserActivitiesCommand.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
108
app/Console/Commands/ExportMissingTranslations.php
Normal file
108
app/Console/Commands/ExportMissingTranslations.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Klevze\ControlPanel\Facades\FileManager;
|
||||
use Klevze\ControlPanel\Core\Utils\Translation as TranslationUtil;
|
||||
|
||||
class ExportMissingTranslations extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'translations:export-missing {file=admin} {--out=}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Export missing translations for a file (e.g. admin) into a CSV';
|
||||
|
||||
private $translationURL = "https://cPad.dev/api/translation/get/list";
|
||||
private $token = 'Ddt06xvjYX1TK792H4jAtld8UhgVORYIpkB7nBX6';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$type = $this->argument('file') ?? 'admin';
|
||||
$this->info('Exporting missing translations for: ' . $type);
|
||||
|
||||
// Gather files to scan
|
||||
$files = [];
|
||||
$files = array_merge(
|
||||
FileManager::getFileList(app_path(), true),
|
||||
FileManager::getFileList(base_path('packages'), true),
|
||||
FileManager::getFileList(resource_path(), true)
|
||||
);
|
||||
|
||||
$tempTranslations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$res = TranslationUtil::findTranslations($file, $type);
|
||||
if (!empty($res) && is_array($res)) {
|
||||
$tempTranslations[] = $res;
|
||||
}
|
||||
}
|
||||
|
||||
$tempTranslations = collect($tempTranslations)->collapse();
|
||||
|
||||
$missing = [];
|
||||
foreach ($tempTranslations as $keycode => $row) {
|
||||
$exists = DB::table('translations')->where('keycode', $keycode)->where('file', $type)->exists();
|
||||
if (! $exists) {
|
||||
$missing[] = $keycode;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Found ' . count($missing) . ' missing keys');
|
||||
|
||||
// Fetch suggested translations from external service for sl and en
|
||||
$suggestions = [];
|
||||
if (!empty($missing)) {
|
||||
$payload = [
|
||||
'keys' => $missing,
|
||||
'languages' => ['sl', 'en'],
|
||||
];
|
||||
|
||||
try {
|
||||
$resp = Http::withToken($this->token)->post($this->translationURL, $payload);
|
||||
if ($resp->successful()) {
|
||||
$suggestions = $resp->json();
|
||||
} else {
|
||||
$this->warn('Translation suggestion service returned ' . $resp->status());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn('Failed to call suggestion service: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Build CSV
|
||||
$out = $this->option('out') ?: storage_path('app/translations_missing_' . $type . '.csv');
|
||||
$fh = fopen($out, 'w');
|
||||
if (! $fh) {
|
||||
$this->error('Failed to open output file: ' . $out);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Header
|
||||
fputcsv($fh, ['file','keycode','suggested_sl','suggested_en','placeholder']);
|
||||
|
||||
foreach ($missing as $key) {
|
||||
$s_sl = $suggestions[$key]['sl'] ?? '';
|
||||
$s_en = $suggestions[$key]['en'] ?? '';
|
||||
$placeholder = $type . '.' . $key;
|
||||
fputcsv($fh, [$type, $key, $s_sl, $s_en, $placeholder]);
|
||||
}
|
||||
|
||||
fclose($fh);
|
||||
|
||||
$this->info('CSV exported to: ' . $out);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
167
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal file
167
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal 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 : '{}';
|
||||
}
|
||||
}
|
||||
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Collect hourly metric snapshots for artworks.
|
||||
*
|
||||
* Runs on cron every hour. Inserts a row per artwork into
|
||||
* artwork_metric_snapshots_hourly with the current totals.
|
||||
* Deltas are computed by the heat recalculation command.
|
||||
*
|
||||
* Usage: php artisan nova:metrics-snapshot-hourly
|
||||
* php artisan nova:metrics-snapshot-hourly --days=30 --chunk=500 --dry-run
|
||||
*/
|
||||
class MetricsSnapshotHourlyCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:metrics-snapshot-hourly
|
||||
{--days=60 : Only snapshot artworks created within this many days}
|
||||
{--chunk=1000 : Chunk size for DB queries}
|
||||
{--dry-run : Log what would be written without persisting}';
|
||||
|
||||
protected $description = 'Collect hourly metric snapshots for rising/heat calculation';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$bucketHour = now()->startOfHour();
|
||||
|
||||
$this->info("[nova:metrics-snapshot-hourly] bucket={$bucketHour->toDateTimeString()} days={$days} chunk={$chunk}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$snapshotCount = 0;
|
||||
$skipCount = 0;
|
||||
|
||||
// Query artworks eligible for snapshotting:
|
||||
// - created within $days OR has a ranking_score above 0
|
||||
// First collect eligible IDs, then process in chunks
|
||||
$eligibleIds = DB::table('artworks')
|
||||
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
|
||||
->where(function ($q) use ($days) {
|
||||
$q->where('artworks.created_at', '>=', now()->subDays($days))
|
||||
->orWhere(function ($q2) {
|
||||
$q2->whereNotNull('s.ranking_score')
|
||||
->where('s.ranking_score', '>', 0);
|
||||
});
|
||||
})
|
||||
->whereNull('artworks.deleted_at')
|
||||
->where('artworks.is_approved', true)
|
||||
->pluck('artworks.id');
|
||||
|
||||
if ($eligibleIds->isEmpty()) {
|
||||
$this->info('No eligible artworks found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($eligibleIds->chunk($chunk) as $chunkIds) {
|
||||
$artworkIds = $chunkIds->values()->all();
|
||||
|
||||
$stats = DB::table('artwork_stats')
|
||||
->whereIn('artwork_id', $artworkIds)
|
||||
->get()
|
||||
->keyBy('artwork_id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($artworkIds as $artworkId) {
|
||||
$stat = $stats->get($artworkId);
|
||||
|
||||
$rows[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'bucket_hour' => $bucketHour,
|
||||
'views_count' => (int) ($stat?->views ?? 0),
|
||||
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
||||
'favourites_count' => (int) ($stat?->favorites ?? 0),
|
||||
'comments_count' => (int) ($stat?->comments_count ?? 0),
|
||||
'shares_count' => (int) ($stat?->shares_count ?? 0),
|
||||
'created_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$snapshotCount += count($rows);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($rows)) {
|
||||
// Upsert: if (artwork_id, bucket_hour) already exists, update totals
|
||||
DB::table('artwork_metric_snapshots_hourly')->upsert(
|
||||
$rows,
|
||||
['artwork_id', 'bucket_hour'],
|
||||
['views_count', 'downloads_count', 'favourites_count', 'comments_count', 'shares_count']
|
||||
);
|
||||
|
||||
$snapshotCount += count($rows);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Snapshots written: {$snapshotCount} | Skipped: {$skipCount}");
|
||||
|
||||
Log::info('[nova:metrics-snapshot-hourly] completed', [
|
||||
'bucket' => $bucketHour->toDateTimeString(),
|
||||
'written' => $snapshotCount,
|
||||
'skipped' => $skipCount,
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
197
app/Console/Commands/MigrateStoriesCommand.php
Normal file
197
app/Console/Commands/MigrateStoriesCommand.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryAuthor;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Migrate legacy interview records into the new Stories system.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan stories:migrate-legacy
|
||||
* php artisan stories:migrate-legacy --dry-run
|
||||
* php artisan stories:migrate-legacy --legacy-connection=legacy --chunk=100
|
||||
*
|
||||
* Idempotent: running multiple times will not duplicate records.
|
||||
* Legacy records are identified via `legacy_interview_id` column on stories table.
|
||||
*/
|
||||
final class MigrateStoriesCommand extends Command
|
||||
{
|
||||
protected $signature = 'stories:migrate-legacy
|
||||
{--chunk=50 : number of records to process per batch}
|
||||
{--dry-run : preview migration without persisting changes}
|
||||
{--legacy-connection= : DB connection name for legacy database (default: uses default connection)}
|
||||
{--legacy-table=interviews : legacy interviews table name}
|
||||
';
|
||||
|
||||
protected $description = 'Migrate legacy interview records into the new nova Stories system (idempotent)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$legacyConn = $this->option('legacy-connection') ?: null;
|
||||
$table = (string) $this->option('legacy-table');
|
||||
|
||||
$this->info('Nova Stories — legacy interview migration');
|
||||
$this->info("Table: {$table} | Chunk: {$chunk} | Dry-run: " . ($dryRun ? 'YES' : 'NO'));
|
||||
$this->newLine();
|
||||
|
||||
try {
|
||||
$db = $legacyConn ? DB::connection($legacyConn) : DB::connection();
|
||||
// Quick existence check
|
||||
$db->table($table)->limit(1)->get();
|
||||
} catch (Throwable $e) {
|
||||
$this->error("Cannot access table `{$table}`: " . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
$db->table($table)->orderBy('id')->chunkById($chunk, function ($rows) use (
|
||||
$dryRun, &$inserted, &$skipped, &$failed
|
||||
) {
|
||||
foreach ($rows as $row) {
|
||||
$legacyId = (int) ($row->id ?? 0);
|
||||
|
||||
if (! $legacyId) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idempotency: skip if already migrated
|
||||
if (Story::where('legacy_interview_id', $legacyId)->exists()) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// ── Resolve / create author ──────────────────────────────
|
||||
$authorName = $this->coerceString($row->username ?? $row->author ?? $row->uname ?? '');
|
||||
$authorAvatar = $this->coerceString($row->icon ?? $row->avatar ?? '');
|
||||
|
||||
$author = null;
|
||||
if ($authorName) {
|
||||
$author = StoryAuthor::firstOrCreate(
|
||||
['name' => $authorName],
|
||||
['avatar' => $authorAvatar ?: null]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Build slug ───────────────────────────────────────────
|
||||
$rawTitle = $this->coerceString(
|
||||
$row->headline ?? $row->title ?? $row->subject ?? ''
|
||||
) ?: 'interview-' . $legacyId;
|
||||
|
||||
$slugBase = Str::slug(Str::limit($rawTitle, 180));
|
||||
$slug = $slugBase ?: 'interview-' . $legacyId;
|
||||
|
||||
// Ensure uniqueness
|
||||
$slug = $this->uniqueSlug($slug);
|
||||
|
||||
// ── Excerpt ──────────────────────────────────────────────
|
||||
$fullContent = $this->coerceString(
|
||||
$row->content ?? $row->tekst ?? $row->body ?? $row->text ?? ''
|
||||
);
|
||||
|
||||
$excerpt = $this->coerceString($row->excerpt ?? $row->intro ?? $row->lead ?? '');
|
||||
if (! $excerpt && $fullContent) {
|
||||
$excerpt = Str::limit(strip_tags($fullContent), 200);
|
||||
}
|
||||
|
||||
// ── Cover image ──────────────────────────────────────────
|
||||
$coverRaw = $this->coerceString($row->pic ?? $row->image ?? $row->cover ?? $row->photo ?? '');
|
||||
$coverImage = $coverRaw ? 'legacy/interviews/' . ltrim($coverRaw, '/') : null;
|
||||
|
||||
// ── Published date ───────────────────────────────────────
|
||||
$publishedAt = null;
|
||||
foreach (['datum', 'published_at', 'date', 'created_at'] as $field) {
|
||||
$val = $row->{$field} ?? null;
|
||||
if ($val) {
|
||||
$ts = strtotime((string) $val);
|
||||
if ($ts) {
|
||||
$publishedAt = date('Y-m-d H:i:s', $ts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [DRY-RUN] Would import: #{$legacyId} → {$slug}");
|
||||
$inserted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Story::create([
|
||||
'slug' => $slug,
|
||||
'title' => Str::limit($rawTitle, 255),
|
||||
'excerpt' => $excerpt ?: null,
|
||||
'content' => $fullContent ?: null,
|
||||
'cover_image' => $coverImage,
|
||||
'author_id' => $author?->id,
|
||||
'views' => max(0, (int) ($row->views ?? $row->hits ?? 0)),
|
||||
'featured' => false,
|
||||
'status' => 'published',
|
||||
'published_at' => $publishedAt,
|
||||
'legacy_interview_id' => $legacyId,
|
||||
]);
|
||||
|
||||
$this->line(" Imported: #{$legacyId} → {$slug}");
|
||||
$inserted++;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" FAILED #{$legacyId}: " . $e->getMessage());
|
||||
Log::warning("stories:migrate-legacy failed for id={$legacyId}", ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Migration complete.");
|
||||
$this->table(
|
||||
['Inserted', 'Skipped (existing)', 'Failed'],
|
||||
[[$inserted, $skipped, $failed]]
|
||||
);
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function coerceString(mixed $value, string $default = ''): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
$str = trim((string) $value);
|
||||
return $str !== '' ? $str : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the slug is unique, appending a numeric suffix if needed.
|
||||
*/
|
||||
private function uniqueSlug(string $slug): string
|
||||
{
|
||||
if (! Story::where('slug', $slug)->exists()) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
$i = 2;
|
||||
do {
|
||||
$candidate = $slug . '-' . $i++;
|
||||
} while (Story::where('slug', $candidate)->exists());
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Prune old hourly metric snapshots to prevent unbounded table growth.
|
||||
*
|
||||
* Usage: php artisan nova:prune-metric-snapshots
|
||||
* php artisan nova:prune-metric-snapshots --keep-days=7
|
||||
*/
|
||||
class PruneMetricSnapshotsCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:prune-metric-snapshots
|
||||
{--keep-days=7 : Keep snapshots for this many days}';
|
||||
|
||||
protected $description = 'Delete old hourly metric snapshots beyond the retention window';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$keepDays = (int) $this->option('keep-days');
|
||||
$cutoff = now()->subDays($keepDays);
|
||||
|
||||
$deleted = DB::table('artwork_metric_snapshots_hourly')
|
||||
->where('bucket_hour', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days.");
|
||||
|
||||
Log::info('[nova:prune-metric-snapshots] completed', [
|
||||
'deleted' => $deleted,
|
||||
'keep_days' => $keepDays,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
127
app/Console/Commands/PublishScheduledArtworksCommand.php
Normal file
127
app/Console/Commands/PublishScheduledArtworksCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
46
app/Console/Commands/PublishScheduledPostsCommand.php
Normal file
46
app/Console/Commands/PublishScheduledPostsCommand.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Publishes posts whose publish_at timestamp has passed.
|
||||
* Scheduled every minute via console/kernel.
|
||||
*/
|
||||
class PublishScheduledPostsCommand extends Command
|
||||
{
|
||||
protected $signature = 'posts:publish-scheduled';
|
||||
protected $description = 'Publish all scheduled posts whose publish_at time has been reached.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = Post::where('status', Post::STATUS_SCHEDULED)
|
||||
->where('publish_at', '<=', now())
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->line('No scheduled posts to publish.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$published = 0;
|
||||
|
||||
Post::where('status', Post::STATUS_SCHEDULED)
|
||||
->where('publish_at', '<=', now())
|
||||
->chunkById(100, function ($posts) use (&$published) {
|
||||
foreach ($posts as $post) {
|
||||
DB::transaction(function () use ($post) {
|
||||
$post->update(['status' => Post::STATUS_PUBLISHED]);
|
||||
});
|
||||
$published++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Published {$published} scheduled post(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Recalculate heat_score for artworks based on hourly metric snapshots.
|
||||
*
|
||||
* Runs every 10–15 minutes via scheduler.
|
||||
*
|
||||
* Formula:
|
||||
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||||
* + comments_delta*8 + shares_delta*12
|
||||
*
|
||||
* age_factor = 1 / (1 + hours_since_upload / 24)
|
||||
*
|
||||
* heat_score = raw_heat * age_factor
|
||||
*
|
||||
* Usage: php artisan nova:recalculate-heat
|
||||
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
|
||||
*/
|
||||
class RecalculateHeatCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:recalculate-heat
|
||||
{--days=60 : Only process artworks created within this many days}
|
||||
{--chunk=1000 : Chunk size for DB queries}
|
||||
{--dry-run : Compute scores without writing to DB}';
|
||||
|
||||
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
|
||||
|
||||
/** Delta weights per the spec */
|
||||
private const WEIGHTS = [
|
||||
'views' => 1,
|
||||
'downloads' => 3,
|
||||
'favourites' => 6,
|
||||
'comments' => 8,
|
||||
'shares' => 12,
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$now = now();
|
||||
$currentHour = $now->copy()->startOfHour();
|
||||
$prevHour = $currentHour->copy()->subHour();
|
||||
|
||||
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$updatedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
// Process in chunks using artwork IDs that have at least one snapshot in the two hours
|
||||
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->distinct()
|
||||
->pluck('artwork_id');
|
||||
|
||||
if ($artworkIds->isEmpty()) {
|
||||
$this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Load all snapshots for the two hours in bulk
|
||||
$snapshots = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->whereIn('artwork_id', $artworkIds)
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
// Load artwork published_at dates for age factor (use published_at, fall back to created_at)
|
||||
$artworkDates = DB::table('artworks')
|
||||
->whereIn('id', $artworkIds)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_approved', true)
|
||||
->select('id', 'published_at', 'created_at')
|
||||
->get()
|
||||
->mapWithKeys(fn ($row) => [
|
||||
$row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at),
|
||||
]);
|
||||
|
||||
// Process in chunks
|
||||
foreach ($artworkIds->chunk($chunk) as $chunkIds) {
|
||||
$upsertRows = [];
|
||||
|
||||
foreach ($chunkIds as $artworkId) {
|
||||
$createdAt = $artworkDates->get($artworkId);
|
||||
if (!$createdAt) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$artworkSnapshots = $snapshots->get($artworkId);
|
||||
if (!$artworkSnapshots || $artworkSnapshots->isEmpty()) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
|
||||
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||||
|
||||
// If we only have one snapshot, use it as current with zero deltas
|
||||
if (!$currentSnapshot && !$prevSnapshot) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate deltas
|
||||
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
||||
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
|
||||
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
|
||||
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
|
||||
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
|
||||
|
||||
// Raw heat
|
||||
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
|
||||
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||||
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||||
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||||
+ ($sharesDelta * self::WEIGHTS['shares']);
|
||||
|
||||
// Age factor: favors newer works
|
||||
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
|
||||
$ageFactor = 1.0 / (1.0 + ($hoursSinceUpload / 24.0));
|
||||
|
||||
// Final heat score
|
||||
$heatScore = max(0, $rawHeat * $ageFactor);
|
||||
|
||||
$upsertRows[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'heat_score' => round($heatScore, 4),
|
||||
'heat_score_updated_at' => $now,
|
||||
'views_1h' => $viewsDelta,
|
||||
'downloads_1h' => $downloadsDelta,
|
||||
'favourites_1h' => $favouritesDelta,
|
||||
'comments_1h' => $commentsDelta,
|
||||
'shares_1h' => $sharesDelta,
|
||||
];
|
||||
|
||||
$updatedCount++;
|
||||
}
|
||||
|
||||
if (!$dryRun && !empty($upsertRows)) {
|
||||
DB::table('artwork_stats')->upsert(
|
||||
$upsertRows,
|
||||
['artwork_id'],
|
||||
['heat_score', 'heat_score_updated_at', 'views_1h', 'downloads_1h', 'favourites_1h', 'comments_1h', 'shares_1h']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Heat scores updated: {$updatedCount} | Skipped: {$skippedCount}");
|
||||
|
||||
Log::info('[nova:recalculate-heat] completed', [
|
||||
'updated' => $updatedCount,
|
||||
'skipped' => $skippedCount,
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
81
app/Console/Commands/RecalculateRankingsCommand.php
Normal file
81
app/Console/Commands/RecalculateRankingsCommand.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Ranking\ArtworkRankingService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* php artisan nova:recalculate-rankings [--chunk=500] [--sync-rank-scores] [--skip-index]
|
||||
*
|
||||
* Ranking Engine V2 — recalculates ranking_score and engagement_velocity
|
||||
* for all public, approved artworks. Designed to run every 30 minutes.
|
||||
*/
|
||||
class RecalculateRankingsCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:recalculate-rankings
|
||||
{--chunk=500 : DB chunk size for batch processing}
|
||||
{--sync-rank-scores : Also update rank_artwork_scores table with V2 formula}
|
||||
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
|
||||
|
||||
protected $description = 'Recalculate V2 ranking scores (engagement + shares + decay + authority + velocity)';
|
||||
|
||||
public function __construct(private readonly ArtworkRankingService $ranking)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunkSize = (int) $this->option('chunk');
|
||||
$syncRankScores = (bool) $this->option('sync-rank-scores');
|
||||
$skipIndex = (bool) $this->option('skip-index');
|
||||
|
||||
// ── Step 1: Recalculate ranking_score + engagement_velocity ─────
|
||||
$this->info('Ranking V2: recalculating scores …');
|
||||
$start = microtime(true);
|
||||
$updated = $this->ranking->recalculateAll($chunkSize);
|
||||
$elapsed = round(microtime(true) - $start, 2);
|
||||
$this->info(" ✓ {$updated} artworks scored in {$elapsed}s");
|
||||
|
||||
// ── Step 2 (optional): Sync to rank_artwork_scores ─────────────
|
||||
if ($syncRankScores) {
|
||||
$this->info('Syncing to rank_artwork_scores …');
|
||||
$start2 = microtime(true);
|
||||
$synced = $this->ranking->syncToRankScores($chunkSize);
|
||||
$elapsed2 = round(microtime(true) - $start2, 2);
|
||||
$this->info(" ✓ {$synced} rank scores synced in {$elapsed2}s");
|
||||
}
|
||||
|
||||
// ── Step 3 (optional): Trigger Meilisearch re-index ────────────
|
||||
if (! $skipIndex) {
|
||||
$this->info('Dispatching Meilisearch index jobs …');
|
||||
$this->dispatchIndexJobs();
|
||||
$this->info(' ✓ Index jobs dispatched');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch IndexArtworkJob for artworks updated in the last 24 hours
|
||||
* (or recently scored). Keeps the search index current.
|
||||
*/
|
||||
private function dispatchIndexJobs(): void
|
||||
{
|
||||
\App\Models\Artwork::query()
|
||||
->select('id')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '>=', now()->subDays(30)->toDateTimeString())
|
||||
->chunkById(500, function ($artworks): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
\App\Jobs\IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
163
app/Console/Commands/RecalculateUserXpCommand.php
Normal file
163
app/Console/Commands/RecalculateUserXpCommand.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AchievementService;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RecalculateUserXpCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:recalculate-user-xp
|
||||
{user_id? : The ID of a single user to recompute}
|
||||
{--all : Recompute XP and level for all non-deleted users}
|
||||
{--chunk=1000 : Chunk size when --all is used}
|
||||
{--dry-run : Show computed values without writing}
|
||||
{--sync-achievements : Re-run achievement checks after a live recalculation}';
|
||||
|
||||
protected $description = 'Rebuild stored user XP, level, and rank from user_xp_logs';
|
||||
|
||||
public function handle(XPService $xp, AchievementService $achievements): int
|
||||
{
|
||||
$userId = $this->argument('user_id');
|
||||
$all = (bool) $this->option('all');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$syncAchievements = (bool) $this->option('sync-achievements');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
|
||||
if ($userId !== null && $all) {
|
||||
$this->error('Provide either a user_id or --all, not both.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($userId !== null) {
|
||||
return $this->recalculateSingle((int) $userId, $xp, $achievements, $dryRun, $syncAchievements);
|
||||
}
|
||||
|
||||
if ($all) {
|
||||
return $this->recalculateAll($xp, $achievements, $chunk, $dryRun, $syncAchievements);
|
||||
}
|
||||
|
||||
$this->error('Provide a user_id or use --all.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
private function recalculateSingle(
|
||||
int $userId,
|
||||
XPService $xp,
|
||||
AchievementService $achievements,
|
||||
bool $dryRun,
|
||||
bool $syncAchievements,
|
||||
): int {
|
||||
$exists = DB::table('users')->where('id', $userId)->exists();
|
||||
if (! $exists) {
|
||||
$this->error("User {$userId} not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
|
||||
$this->line("{$label} Recomputing XP for user #{$userId}...");
|
||||
|
||||
$result = $xp->recalculateStoredProgress($userId, ! $dryRun);
|
||||
$this->table(
|
||||
['Field', 'Stored', 'Computed'],
|
||||
[
|
||||
['xp', $result['previous']['xp'], $result['computed']['xp']],
|
||||
['level', $result['previous']['level'], $result['computed']['level']],
|
||||
['rank', $result['previous']['rank'], $result['computed']['rank']],
|
||||
]
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
if ($syncAchievements) {
|
||||
$pending = $achievements->previewUnlocks($userId);
|
||||
$this->line('Achievements preview: ' . (empty($pending) ? 'no pending unlocks' : implode(', ', $pending)));
|
||||
}
|
||||
|
||||
$this->warn('Dry-run: no changes written.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($syncAchievements) {
|
||||
$unlocked = $achievements->checkAchievements($userId);
|
||||
$this->line('Achievements checked: ' . (empty($unlocked) ? 'no new unlocks' : implode(', ', $unlocked)));
|
||||
}
|
||||
|
||||
$this->info($result['changed'] ? "XP updated for user #{$userId}." : "User #{$userId} was already in sync.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function recalculateAll(
|
||||
XPService $xp,
|
||||
AchievementService $achievements,
|
||||
int $chunk,
|
||||
bool $dryRun,
|
||||
bool $syncAchievements,
|
||||
): int {
|
||||
$total = DB::table('users')->whereNull('deleted_at')->count();
|
||||
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
|
||||
|
||||
$this->info("{$label} Recomputing XP for {$total} users (chunk={$chunk})...");
|
||||
|
||||
$processed = 0;
|
||||
$changed = 0;
|
||||
$pendingAchievementUsers = 0;
|
||||
$pendingAchievementUnlocks = 0;
|
||||
$appliedAchievementUnlocks = 0;
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->start();
|
||||
|
||||
DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($users) use ($xp, $achievements, $dryRun, $syncAchievements, &$processed, &$changed, &$pendingAchievementUsers, &$pendingAchievementUnlocks, &$appliedAchievementUnlocks, $bar): void {
|
||||
foreach ($users as $user) {
|
||||
$result = $xp->recalculateStoredProgress((int) $user->id, ! $dryRun);
|
||||
|
||||
if ($result['changed']) {
|
||||
$changed++;
|
||||
}
|
||||
|
||||
if ($syncAchievements) {
|
||||
if ($dryRun) {
|
||||
$pending = $achievements->previewUnlocks((int) $user->id);
|
||||
if (! empty($pending)) {
|
||||
$pendingAchievementUsers++;
|
||||
$pendingAchievementUnlocks += count($pending);
|
||||
}
|
||||
} else {
|
||||
$unlocked = $achievements->checkAchievements((int) $user->id);
|
||||
$appliedAchievementUnlocks += count($unlocked);
|
||||
}
|
||||
}
|
||||
|
||||
$processed++;
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$summary = "Done - {$processed} users processed, {$changed} " . ($dryRun ? 'would change.' : 'updated.');
|
||||
if ($syncAchievements) {
|
||||
if ($dryRun) {
|
||||
$summary .= " Achievement preview: {$pendingAchievementUnlocks} pending unlock(s) across {$pendingAchievementUsers} user(s).";
|
||||
} else {
|
||||
$summary .= " Achievements re-checked: {$appliedAchievementUnlocks} unlock(s) applied.";
|
||||
}
|
||||
}
|
||||
|
||||
$this->info($summary);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
342
app/Console/Commands/RepairLegacyUserJoinDatesCommand.php
Normal file
342
app/Console/Commands/RepairLegacyUserJoinDatesCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal file
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class RepairLegacyWallzUsersCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:repair-legacy-wallz-users
|
||||
{--chunk=500 : Number of legacy wallz rows to scan per batch}
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-table=wallz : Legacy table to update}
|
||||
{--artworks-table=artworks : Current DB artworks table name}
|
||||
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
|
||||
{--dry-run : Preview matches and inserts without writing changes}';
|
||||
|
||||
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$legacyConnection = (string) $this->option('legacy-connection');
|
||||
$legacyTable = (string) $this->option('legacy-table');
|
||||
$artworksTable = (string) $this->option('artworks-table');
|
||||
$fixArtworks = (bool) $this->option('fix-artworks');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
|
||||
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No changes will be written.');
|
||||
}
|
||||
|
||||
if ($fixArtworks) {
|
||||
$this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun);
|
||||
}
|
||||
|
||||
$total = (int) DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->where('user_id', 0)
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
if (! $fixArtworks) {
|
||||
$this->info('No legacy wallz rows with user_id = 0 were found.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}.");
|
||||
|
||||
$processed = 0;
|
||||
$updatedRows = 0;
|
||||
$matchedUsers = 0;
|
||||
$createdUsers = 0;
|
||||
$skippedRows = 0;
|
||||
$usernameMap = [];
|
||||
|
||||
DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->select(['id', 'uname'])
|
||||
->where('user_id', 0)
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($rows) use (
|
||||
&$processed,
|
||||
&$updatedRows,
|
||||
&$matchedUsers,
|
||||
&$createdUsers,
|
||||
&$skippedRows,
|
||||
&$usernameMap,
|
||||
$dryRun,
|
||||
$legacyConnection,
|
||||
$legacyTable
|
||||
) {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$rawUsername = trim((string) ($row->uname ?? ''));
|
||||
if ($rawUsername === '') {
|
||||
$skippedRows++;
|
||||
$this->warn("Skipping wallz id={$row->id}: uname is empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$lookupKey = UsernamePolicy::normalize($rawUsername);
|
||||
if ($lookupKey === '') {
|
||||
$skippedRows++;
|
||||
$this->warn("Skipping wallz id={$row->id}: uname normalizes to empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($lookupKey, $usernameMap)) {
|
||||
$existingUser = $this->findUserByUsername($lookupKey);
|
||||
|
||||
if ($existingUser !== null) {
|
||||
$usernameMap[$lookupKey] = [
|
||||
'user_id' => (int) $existingUser->id,
|
||||
'created' => false,
|
||||
];
|
||||
} else {
|
||||
$usernameMap[$lookupKey] = [
|
||||
'user_id' => $dryRun
|
||||
? 0
|
||||
: $this->createUserForLegacyUsername($rawUsername, $legacyConnection),
|
||||
'created' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = $usernameMap[$lookupKey];
|
||||
|
||||
if ($resolved['created']) {
|
||||
$createdUsers++;
|
||||
$usernameMap[$lookupKey]['created'] = false;
|
||||
$resolved['created'] = false;
|
||||
$this->line($dryRun
|
||||
? "[dry] Would create user for uname='{$rawUsername}'"
|
||||
: "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'");
|
||||
} else {
|
||||
$matchedUsers++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$targetUser = $usernameMap[$lookupKey]['user_id'] > 0
|
||||
? (string) $usernameMap[$lookupKey]['user_id']
|
||||
: '<new-user-id>';
|
||||
$this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'");
|
||||
$updatedRows++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$affected = DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->where('id', $row->id)
|
||||
->where('user_id', 0)
|
||||
->update([
|
||||
'user_id' => $usernameMap[$lookupKey]['user_id'],
|
||||
]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$updatedRows += $affected;
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$this->info(sprintf(
|
||||
'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d',
|
||||
$processed,
|
||||
$updatedRows,
|
||||
$matchedUsers,
|
||||
$createdUsers,
|
||||
$skippedRows
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void
|
||||
{
|
||||
$this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0");
|
||||
|
||||
$total = (int) DB::table($artworksTable)->where('user_id', 0)->count();
|
||||
$this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}.");
|
||||
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
DB::table($artworksTable)
|
||||
->select(['id'])
|
||||
->where('user_id', 0)
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$legacyUser = DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->where('id', $row->id)
|
||||
->value('user_id');
|
||||
|
||||
$legacyUser = (int) ($legacyUser ?? 0);
|
||||
if ($legacyUser <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}");
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$affected = DB::table($artworksTable)
|
||||
->where('id', $row->id)
|
||||
->where('user_id', 0)
|
||||
->update(['user_id' => $legacyUser]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$updated += $affected;
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated));
|
||||
}
|
||||
|
||||
private function legacyTableExists(string $connection, string $table): bool
|
||||
{
|
||||
try {
|
||||
return DB::connection($connection)->getSchemaBuilder()->hasTable($table);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function findUserByUsername(string $normalizedUsername): ?object
|
||||
{
|
||||
return DB::table('users')
|
||||
->select(['id', 'username'])
|
||||
->whereRaw('LOWER(username) = ?', [$normalizedUsername])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int
|
||||
{
|
||||
$username = UsernamePolicy::uniqueCandidate($legacyUsername);
|
||||
$emailLocal = $this->sanitizeEmailLocal($username);
|
||||
$email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org');
|
||||
$now = now();
|
||||
|
||||
// Attempt to copy legacy joinDate from the legacy `users` table when available.
|
||||
$legacyJoin = null;
|
||||
try {
|
||||
$legacyJoin = DB::connection($legacyConnection)
|
||||
->table('users')
|
||||
->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)])
|
||||
->value('joinDate');
|
||||
} catch (\Throwable) {
|
||||
$legacyJoin = null;
|
||||
}
|
||||
|
||||
$createdAt = $now;
|
||||
if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) {
|
||||
try {
|
||||
$createdAt = Carbon::parse($legacyJoin);
|
||||
} catch (\Throwable) {
|
||||
$createdAt = $now;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = (int) DB::table('users')->insertGetId([
|
||||
'username' => $username,
|
||||
'username_changed_at' => $now,
|
||||
'name' => $legacyUsername,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => true,
|
||||
'needs_password_reset' => true,
|
||||
'role' => 'user',
|
||||
'legacy_password_algo' => null,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
private function uniqueEmailCandidate(string $email): string
|
||||
{
|
||||
$candidate = strtolower(trim($email));
|
||||
$suffix = 1;
|
||||
|
||||
while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) {
|
||||
$parts = explode('@', $email, 2);
|
||||
$local = $parts[0] ?? 'user';
|
||||
$domain = $parts[1] ?? 'users.skinbase.org';
|
||||
$candidate = $local . '+' . $suffix . '@' . $domain;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim($value));
|
||||
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
||||
|
||||
return trim($local, '.-') ?: 'user';
|
||||
}
|
||||
}
|
||||
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal file
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RepairTemporaryUsernamesCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:repair-temp-usernames
|
||||
{--chunk=500 : Number of users to process per batch}
|
||||
{--dry-run : Preview username changes without writing them}';
|
||||
|
||||
protected $description = 'Replace current users.username values like tmpu% using the users.name field';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No changes will be written.');
|
||||
}
|
||||
|
||||
$total = (int) DB::table('users')
|
||||
->where('username', 'like', 'tmpu%')
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('No users with temporary tmpu% usernames were found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$total} users with temporary tmpu% usernames.");
|
||||
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
DB::table('users')
|
||||
->select(['id', 'name', 'username'])
|
||||
->where('username', 'like', 'tmpu%')
|
||||
->chunkById($chunk, function ($rows) use (&$processed, &$updated, &$skipped, $dryRun) {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$sourceName = trim((string) ($row->name ?? ''));
|
||||
if ($sourceName === '') {
|
||||
$skipped++;
|
||||
$this->warn("Skipping user id={$row->id}: name is empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $this->resolveCandidate($sourceName, (int) $row->id);
|
||||
|
||||
if ($candidate === null || strcasecmp($candidate, (string) $row->username) === 0) {
|
||||
$skipped++;
|
||||
$this->warn("Skipping user id={$row->id}: unable to resolve a better username from name='{$sourceName}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[dry] Would update user id={$row->id} username '{$row->username}' => '{$candidate}'");
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$affected = DB::table('users')
|
||||
->where('id', (int) $row->id)
|
||||
->where('username', 'like', 'tmpu%')
|
||||
->update([
|
||||
'username' => $candidate,
|
||||
'username_changed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$updated += $affected;
|
||||
$this->line("[update] user id={$row->id} username '{$row->username}' => '{$candidate}'");
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$this->info(sprintf('Finished. processed=%d updated=%d skipped=%d', $processed, $updated, $skipped));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveCandidate(string $sourceName, int $userId): ?string
|
||||
{
|
||||
$base = UsernamePolicy::sanitizeLegacy($sourceName);
|
||||
$min = UsernamePolicy::min();
|
||||
$max = UsernamePolicy::max();
|
||||
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^tmpu\d+$/i', $base) === 1) {
|
||||
$base = 'user' . $userId;
|
||||
}
|
||||
|
||||
if (strlen($base) < $min) {
|
||||
$base = substr($base . $userId, 0, $max);
|
||||
}
|
||||
|
||||
if ($base === '' || $base === 'user') {
|
||||
$base = 'user' . $userId;
|
||||
}
|
||||
|
||||
$candidate = substr($base, 0, $max);
|
||||
$suffix = 1;
|
||||
|
||||
while ($this->usernameExists($candidate, $userId) || UsernamePolicy::isReserved($candidate)) {
|
||||
$suffixValue = (string) $suffix;
|
||||
$prefixLen = max(1, $max - strlen($suffixValue));
|
||||
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function usernameExists(string $username, int $ignoreUserId): bool
|
||||
{
|
||||
return DB::table('users')
|
||||
->whereRaw('LOWER(username) = ?', [strtolower($username)])
|
||||
->where('id', '!=', $ignoreUserId)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
105
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal file
105
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
167
app/Console/Commands/SeedTagInteractionDemoCommand.php
Normal file
167
app/Console/Commands/SeedTagInteractionDemoCommand.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SeedTagInteractionDemoCommand extends Command
|
||||
{
|
||||
protected $signature = 'analytics:seed-tag-interaction-demo
|
||||
{--days=14 : Number of days to generate demo events for}
|
||||
{--per-day=90 : Approximate number of demo events to write per day}
|
||||
{--refresh : Remove existing seeded demo events first}
|
||||
{--force : Allow running outside local/testing environments}';
|
||||
|
||||
protected $description = 'Generate demo tag interaction events for local analytics dashboards and ranking validation';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! app()->environment(['local', 'testing']) && ! $this->option('force')) {
|
||||
$this->error('This command is restricted to local/testing unless --force is provided.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$days = max(1, min(60, (int) $this->option('days')));
|
||||
$perDay = max(10, min(500, (int) $this->option('per-day')));
|
||||
|
||||
$tags = Tag::query()
|
||||
->where('is_active', true)
|
||||
->orderByDesc('usage_count')
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'slug', 'usage_count']);
|
||||
|
||||
if ($tags->count() < 2) {
|
||||
$this->error('At least two active tags are required to generate demo interaction data.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$transitions = $this->buildTransitionMap($tags);
|
||||
|
||||
if ($this->option('refresh')) {
|
||||
DB::table('tag_interaction_events')
|
||||
->where('meta->seeded_demo', true)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
for ($offset = $days - 1; $offset >= 0; $offset--) {
|
||||
$date = Carbon::today()->subDays($offset);
|
||||
$rows = [];
|
||||
|
||||
for ($index = 0; $index < $perDay; $index++) {
|
||||
$surface = $this->pickSurface();
|
||||
$sourceTag = $tags->random();
|
||||
$targetTag = $this->pickTargetTag($surface, $sourceTag->slug, $transitions, $tags);
|
||||
$query = in_array($surface, ['search_suggestion', 'rescue_suggestion', 'recent_search'], true)
|
||||
? $this->queryForTag($targetTag)
|
||||
: null;
|
||||
|
||||
$rows[] = [
|
||||
'event_date' => $date->toDateString(),
|
||||
'event_type' => 'click',
|
||||
'surface' => $surface,
|
||||
'user_id' => null,
|
||||
'session_key' => hash('sha256', 'demo-' . $date->toDateString() . '-' . $index . '-' . $surface),
|
||||
'tag_slug' => $targetTag->slug,
|
||||
'source_tag_slug' => in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)
|
||||
? $sourceTag->slug
|
||||
: null,
|
||||
'query' => $query,
|
||||
'position' => random_int(1, 4),
|
||||
'meta' => json_encode([
|
||||
'seeded_demo' => true,
|
||||
'seeded_at' => $now->toISOString(),
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'occurred_at' => $date->copy()->setTime(random_int(8, 23), random_int(0, 59), random_int(0, 59)),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 250) as $chunk) {
|
||||
DB::table('tag_interaction_events')->insert($chunk);
|
||||
}
|
||||
|
||||
$this->call('analytics:aggregate-tag-interactions', ['--date' => $date->toDateString()]);
|
||||
}
|
||||
|
||||
$this->info("Seeded demo tag interaction events for the last {$days} days.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function buildTransitionMap(Collection $tags): array
|
||||
{
|
||||
$pairs = DB::table('artwork_tag as source_pivot')
|
||||
->join('tags as source_tag', 'source_tag.id', '=', 'source_pivot.tag_id')
|
||||
->join('artwork_tag as target_pivot', 'target_pivot.artwork_id', '=', 'source_pivot.artwork_id')
|
||||
->join('tags as target_tag', 'target_tag.id', '=', 'target_pivot.tag_id')
|
||||
->whereIn('source_tag.id', $tags->pluck('id')->all())
|
||||
->whereIn('target_tag.id', $tags->pluck('id')->all())
|
||||
->whereColumn('source_tag.id', '!=', 'target_tag.id')
|
||||
->groupBy('source_tag.slug', 'target_tag.slug')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->get([
|
||||
'source_tag.slug as source_slug',
|
||||
'target_tag.slug as target_slug',
|
||||
DB::raw('COUNT(*) as pair_count'),
|
||||
]);
|
||||
|
||||
$map = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$map[$pair->source_slug][] = $pair->target_slug;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function pickSurface(): string
|
||||
{
|
||||
$roll = random_int(1, 100);
|
||||
|
||||
return match (true) {
|
||||
$roll <= 32 => 'search_suggestion',
|
||||
$roll <= 46 => 'rescue_suggestion',
|
||||
$roll <= 58 => 'recent_search',
|
||||
$roll <= 80 => 'related_chip',
|
||||
$roll <= 94 => 'related_cluster',
|
||||
default => 'top_companion',
|
||||
};
|
||||
}
|
||||
|
||||
private function pickTargetTag(string $surface, string $sourceSlug, array $transitions, Collection $tags): object
|
||||
{
|
||||
if (in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)) {
|
||||
$candidateSlugs = $transitions[$sourceSlug] ?? [];
|
||||
if ($candidateSlugs !== []) {
|
||||
$slug = $candidateSlugs[array_rand($candidateSlugs)];
|
||||
return $tags->firstWhere('slug', $slug) ?? $tags->where('slug', '!=', $sourceSlug)->random();
|
||||
}
|
||||
|
||||
return $tags->where('slug', '!=', $sourceSlug)->random();
|
||||
}
|
||||
|
||||
return $tags->random();
|
||||
}
|
||||
|
||||
private function queryForTag(object $tag): string
|
||||
{
|
||||
$name = trim((string) ($tag->name ?? $tag->slug));
|
||||
$options = array_values(array_filter([
|
||||
strtolower($name),
|
||||
strtolower((string) ($tag->slug ?? '')),
|
||||
strtolower(substr($name, 0, max(3, min(strlen($name), 7)))),
|
||||
]));
|
||||
|
||||
return $options[array_rand($options)];
|
||||
}
|
||||
}
|
||||
46
app/Console/Commands/SyncCollectionLifecycleCommand.php
Normal file
46
app/Console/Commands/SyncCollectionLifecycleCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
50
app/Console/Commands/SyncCountriesCommand.php
Normal file
50
app/Console/Commands/SyncCountriesCommand.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Countries\CountrySyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
final class SyncCountriesCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sync-countries
|
||||
{--deactivate-missing : Mark countries missing from the source as inactive}
|
||||
{--no-fallback : Fail instead of using the local fallback dataset when remote fetch fails}';
|
||||
|
||||
protected $description = 'Synchronize ISO 3166 country metadata into the local countries table.';
|
||||
|
||||
public function __construct(
|
||||
private readonly CountrySyncService $countrySyncService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
$summary = $this->countrySyncService->sync(
|
||||
allowFallback: ! (bool) $this->option('no-fallback'),
|
||||
deactivateMissing: (bool) $this->option('deactivate-missing') ? true : null,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Countries synchronized successfully.');
|
||||
$this->line('Source: '.($summary['source'] ?? 'unknown'));
|
||||
$this->line('Fetched: '.(int) ($summary['total_fetched'] ?? 0));
|
||||
$this->line('Inserted: '.(int) ($summary['inserted'] ?? 0));
|
||||
$this->line('Updated: '.(int) ($summary['updated'] ?? 0));
|
||||
$this->line('Skipped: '.(int) ($summary['skipped'] ?? 0));
|
||||
$this->line('Invalid: '.(int) ($summary['invalid'] ?? 0));
|
||||
$this->line('Deactivated: '.(int) ($summary['deactivated'] ?? 0));
|
||||
$this->line('Backfilled users: '.(int) ($summary['backfilled_users'] ?? 0));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
149
app/Console/Commands/TestObjectStorageUploadCommand.php
Normal file
149
app/Console/Commands/TestObjectStorageUploadCommand.php
Normal 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(),
|
||||
'',
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/WarmPostTrendingCommand.php
Normal file
28
app/Console/Commands/WarmPostTrendingCommand.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Posts\PostTrendingService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Warms the post trending cache so requests are fast.
|
||||
* Scheduled every 2 minutes to match the cache TTL.
|
||||
*/
|
||||
class WarmPostTrendingCommand extends Command
|
||||
{
|
||||
protected $signature = 'posts:warm-trending';
|
||||
protected $description = 'Refresh the post trending feed cache.';
|
||||
|
||||
public function __construct(private PostTrendingService $trending)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$ids = $this->trending->refresh();
|
||||
$this->info('Trending feed cache refreshed. ' . count($ids) . ' post(s) ranked.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
15
app/Events/Achievements/AchievementCheckRequested.php
Normal file
15
app/Events/Achievements/AchievementCheckRequested.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events\Achievements;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class AchievementCheckRequested
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly int $userId) {}
|
||||
}
|
||||
15
app/Events/Achievements/UserXpUpdated.php
Normal file
15
app/Events/Achievements/UserXpUpdated.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events\Achievements;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UserXpUpdated
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly int $userId) {}
|
||||
}
|
||||
19
app/Events/Collections/CollectionArtworkAttached.php
Normal file
19
app/Events/Collections/CollectionArtworkAttached.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionArtworkRemoved.php
Normal file
16
app/Events/Collections/CollectionArtworkRemoved.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionCreated.php
Normal file
16
app/Events/Collections/CollectionCreated.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionDeleted.php
Normal file
16
app/Events/Collections/CollectionDeleted.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionFeatured.php
Normal file
16
app/Events/Collections/CollectionFeatured.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionFollowed.php
Normal file
16
app/Events/Collections/CollectionFollowed.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionLiked.php
Normal file
16
app/Events/Collections/CollectionLiked.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionShared.php
Normal file
16
app/Events/Collections/CollectionShared.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionUnfeatured.php
Normal file
16
app/Events/Collections/CollectionUnfeatured.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionUnfollowed.php
Normal file
16
app/Events/Collections/CollectionUnfollowed.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionUnliked.php
Normal file
16
app/Events/Collections/CollectionUnliked.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionUpdated.php
Normal file
16
app/Events/Collections/CollectionUpdated.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/CollectionViewed.php
Normal file
16
app/Events/Collections/CollectionViewed.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/Collections/SmartCollectionRulesUpdated.php
Normal file
16
app/Events/Collections/SmartCollectionRulesUpdated.php
Normal 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) {}
|
||||
}
|
||||
50
app/Events/ConversationUpdated.php
Normal file
50
app/Events/ConversationUpdated.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use App\Services\Messaging\UnreadCounterService;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ConversationUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public bool $afterCommit = true;
|
||||
public string $queue;
|
||||
|
||||
public function __construct(
|
||||
public int $userId,
|
||||
public Conversation $conversation,
|
||||
public string $reason,
|
||||
) {
|
||||
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('user.' . $this->userId)];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'conversation.updated';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'event' => 'conversation.updated',
|
||||
'reason' => $this->reason,
|
||||
'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId),
|
||||
'summary' => [
|
||||
'unread_total' => app(UnreadCounterService::class)->totalUnreadForUser($this->userId),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Events/MessageCreated.php
Normal file
51
app/Events/MessageCreated.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Message;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MessageCreated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public bool $afterCommit = true;
|
||||
public string $queue;
|
||||
|
||||
public function __construct(
|
||||
public Conversation $conversation,
|
||||
public Message $message,
|
||||
int $originUserId,
|
||||
) {
|
||||
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||
|
||||
if ($originUserId === (int) $message->sender_id) {
|
||||
$this->dontBroadcastToCurrentUser();
|
||||
}
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('conversation.' . $this->conversation->id)];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.created';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'event' => 'message.created',
|
||||
'conversation_id' => (int) $this->conversation->id,
|
||||
'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id),
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Events/MessageDeleted.php
Normal file
45
app/Events/MessageDeleted.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Message;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MessageDeleted implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public bool $afterCommit = true;
|
||||
public string $queue;
|
||||
|
||||
public function __construct(public Message $message)
|
||||
{
|
||||
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||
$this->dontBroadcastToCurrentUser();
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('conversation.' . $this->message->conversation_id)];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.deleted';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'event' => 'message.deleted',
|
||||
'conversation_id' => (int) $this->message->conversation_id,
|
||||
'message_id' => (int) $this->message->id,
|
||||
'uuid' => (string) $this->message->uuid,
|
||||
'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Events/MessageRead.php
Normal file
51
app/Events/MessageRead.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\User;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MessageRead implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public bool $afterCommit = true;
|
||||
public string $queue;
|
||||
|
||||
public function __construct(
|
||||
public Conversation $conversation,
|
||||
public ConversationParticipant $participant,
|
||||
public User $reader,
|
||||
) {
|
||||
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||
$this->dontBroadcastToCurrentUser();
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('conversation.' . $this->conversation->id)];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.read';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'event' => 'message.read',
|
||||
'conversation_id' => (int) $this->conversation->id,
|
||||
'user' => app(MessagingPayloadFactory::class)->userSummary($this->reader),
|
||||
'last_read_message_id' => $this->participant->last_read_message_id ? (int) $this->participant->last_read_message_id : null,
|
||||
'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
44
app/Events/MessageUpdated.php
Normal file
44
app/Events/MessageUpdated.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Message;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MessageUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public bool $afterCommit = true;
|
||||
public string $queue;
|
||||
|
||||
public function __construct(public Message $message)
|
||||
{
|
||||
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
|
||||
$this->dontBroadcastToCurrentUser();
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('conversation.' . $this->message->conversation_id)];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.updated';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'event' => 'message.updated',
|
||||
'conversation_id' => (int) $this->message->conversation_id,
|
||||
'message' => app(MessagingPayloadFactory::class)->message($this->message),
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Events/NovaCards/NovaCardAutosaved.php
Normal file
16
app/Events/NovaCards/NovaCardAutosaved.php
Normal 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 = []) {}
|
||||
}
|
||||
17
app/Events/NovaCards/NovaCardBackgroundUploaded.php
Normal file
17
app/Events/NovaCards/NovaCardBackgroundUploaded.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/NovaCards/NovaCardCreated.php
Normal file
16
app/Events/NovaCards/NovaCardCreated.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/NovaCards/NovaCardDownloaded.php
Normal file
16
app/Events/NovaCards/NovaCardDownloaded.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/NovaCards/NovaCardPublished.php
Normal file
16
app/Events/NovaCards/NovaCardPublished.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/NovaCards/NovaCardShared.php
Normal file
16
app/Events/NovaCards/NovaCardShared.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/NovaCards/NovaCardTemplateSelected.php
Normal file
16
app/Events/NovaCards/NovaCardTemplateSelected.php
Normal 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) {}
|
||||
}
|
||||
16
app/Events/NovaCards/NovaCardViewed.php
Normal file
16
app/Events/NovaCards/NovaCardViewed.php
Normal 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) {}
|
||||
}
|
||||
20
app/Events/Posts/ArtworkShared.php
Normal file
20
app/Events/Posts/ArtworkShared.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Posts;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ArtworkShared
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Post $post,
|
||||
public readonly Artwork $artwork,
|
||||
public readonly User $sharer,
|
||||
) {}
|
||||
}
|
||||
20
app/Events/Posts/PostCommented.php
Normal file
20
app/Events/Posts/PostCommented.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PostCommented
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Post $post,
|
||||
public readonly PostComment $comment,
|
||||
public readonly User $commenter,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -105,7 +145,9 @@ final class ArtworkInteractionController extends Controller
|
||||
}
|
||||
|
||||
$svc = app(FollowService::class);
|
||||
$state = $request->boolean('state', true);
|
||||
$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,
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
49
app/Http/Controllers/Api/CommunityActivityController.php
Normal file
49
app/Http/Controllers/Api/CommunityActivityController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CommunityActivityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class CommunityActivityController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CommunityActivityService $activityService)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$filter = $this->resolveFilter($request);
|
||||
|
||||
if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$feed = $this->activityService->getFeed(
|
||||
viewer: $request->user(),
|
||||
filter: $filter,
|
||||
page: (int) $request->query('page', 1),
|
||||
perPage: (int) $request->query('per_page', CommunityActivityService::DEFAULT_PER_PAGE),
|
||||
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||
);
|
||||
|
||||
return response()->json($feed);
|
||||
}
|
||||
|
||||
private function resolveFilter(Request $request): string
|
||||
{
|
||||
if ($request->filled('type') && ! $request->filled('filter')) {
|
||||
return (string) $request->query('type', 'all');
|
||||
}
|
||||
|
||||
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||
return 'following';
|
||||
}
|
||||
|
||||
return (string) $request->query('filter', 'all');
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
206
app/Http/Controllers/Api/DiscoveryNegativeSignalController.php
Normal file
206
app/Http/Controllers/Api/DiscoveryNegativeSignalController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
53
app/Http/Controllers/Api/ImageSearchController.php
Normal file
53
app/Http/Controllers/Api/ImageSearchController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
35
app/Http/Controllers/Api/LeaderboardController.php
Normal file
35
app/Http/Controllers/Api/LeaderboardController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Services\LeaderboardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class LeaderboardController extends Controller
|
||||
{
|
||||
public function creators(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_CREATOR, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function artworks(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_ARTWORK, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
}
|
||||
223
app/Http/Controllers/Api/LinkPreviewController.php
Normal file
223
app/Http/Controllers/Api/LinkPreviewController.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\TransferException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LinkPreviewController extends Controller
|
||||
{
|
||||
private const TIMEOUT = 8; // seconds
|
||||
private const MAX_BYTES = 524_288; // 512 KB – enough to get the <head>
|
||||
private const USER_AGENT = 'Skinbase-LinkPreview/1.0 (+https://skinbase.org)';
|
||||
|
||||
/** Blocked IP ranges (SSRF protection). */
|
||||
private const BLOCKED_CIDRS = [
|
||||
'0.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'100.64.0.0/10',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'172.16.0.0/12',
|
||||
'192.0.0.0/24',
|
||||
'192.168.0.0/16',
|
||||
'198.18.0.0/15',
|
||||
'198.51.100.0/24',
|
||||
'203.0.113.0/24',
|
||||
'240.0.0.0/4',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'url' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$rawUrl = trim((string) $request->input('url'));
|
||||
|
||||
// Must be http(s)
|
||||
if (! preg_match('#^https?://#i', $rawUrl)) {
|
||||
return response()->json(['error' => 'Invalid URL scheme.'], 422);
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$host = $parsed['host'] ?? '';
|
||||
|
||||
if (empty($host)) {
|
||||
return response()->json(['error' => 'Invalid URL.'], 422);
|
||||
}
|
||||
|
||||
// Resolve hostname and block private/loopback IPs (SSRF protection)
|
||||
$resolved = gethostbyname($host);
|
||||
if ($this->isBlockedIp($resolved)) {
|
||||
return response()->json(['error' => 'URL not allowed.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new Client([
|
||||
'timeout' => self::TIMEOUT,
|
||||
'connect_timeout' => 4,
|
||||
'allow_redirects' => ['max' => 5, 'strict' => false],
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
'Accept' => 'text/html,application/xhtml+xml',
|
||||
],
|
||||
'verify' => true,
|
||||
]);
|
||||
|
||||
$response = $client->get($rawUrl);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if ($status < 200 || $status >= 400) {
|
||||
return response()->json(['error' => 'Could not fetch URL.'], 422);
|
||||
}
|
||||
|
||||
// Read up to MAX_BYTES – we only need the HTML <head>
|
||||
$body = '';
|
||||
$stream = $response->getBody();
|
||||
while (! $stream->eof() && strlen($body) < self::MAX_BYTES) {
|
||||
$body .= $stream->read(4096);
|
||||
}
|
||||
$stream->close();
|
||||
|
||||
} catch (TransferException $e) {
|
||||
return response()->json(['error' => 'Could not reach URL.'], 422);
|
||||
}
|
||||
|
||||
$preview = $this->extractMeta($body, $rawUrl);
|
||||
|
||||
return response()->json($preview);
|
||||
}
|
||||
|
||||
/** Extract OG / Twitter / fallback meta tags. */
|
||||
private function extractMeta(string $html, string $originalUrl): array
|
||||
{
|
||||
// Limit to roughly the <head> block for speed
|
||||
$head = substr($html, 0, 50_000);
|
||||
|
||||
$og = [];
|
||||
|
||||
// OG / Twitter meta tags
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m1,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m2,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
|
||||
$allMeta = array_merge(
|
||||
array_map(fn ($r) => ['key' => strtolower($r[1]), 'value' => $r[2]], $m1),
|
||||
array_map(fn ($r) => ['key' => strtolower($r[2]), 'value' => $r[1]], $m2),
|
||||
);
|
||||
|
||||
$map = [];
|
||||
foreach ($allMeta as $entry) {
|
||||
$map[$entry['key']] ??= $entry['value'];
|
||||
}
|
||||
|
||||
// Canonical URL
|
||||
$canonical = $originalUrl;
|
||||
if (preg_match('/<link[^>]+rel\s*=\s*["\']canonical["\'][^>]+href\s*=\s*["\']([^"\']+)["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
} elseif (preg_match('/<link[^>]+href\s*=\s*["\']([^"\']+)["\'][^>]+rel\s*=\s*["\']canonical["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
}
|
||||
|
||||
// Title
|
||||
$title = $map['og:title']
|
||||
?? $map['twitter:title']
|
||||
?? null;
|
||||
if (! $title && preg_match('/<title[^>]*>([^<]+)<\/title>/i', $head, $mt)) {
|
||||
$title = trim(html_entity_decode($mt[1]));
|
||||
}
|
||||
|
||||
// Description
|
||||
$description = $map['og:description']
|
||||
?? $map['twitter:description']
|
||||
?? $map['description']
|
||||
?? null;
|
||||
|
||||
// Image
|
||||
$image = $map['og:image']
|
||||
?? $map['twitter:image']
|
||||
?? $map['twitter:image:src']
|
||||
?? null;
|
||||
|
||||
// Resolve relative image URL
|
||||
if ($image && ! preg_match('#^https?://#i', $image)) {
|
||||
$parsed = parse_url($originalUrl);
|
||||
$base = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
|
||||
$image = $base . '/' . ltrim($image, '/');
|
||||
}
|
||||
|
||||
// Site name
|
||||
$siteName = $map['og:site_name'] ?? parse_url($originalUrl, PHP_URL_HOST) ?? null;
|
||||
|
||||
return [
|
||||
'url' => $canonical,
|
||||
'title' => $title ? html_entity_decode($title) : null,
|
||||
'description' => $description ? html_entity_decode($description) : null,
|
||||
'image' => $image,
|
||||
'site_name' => $siteName,
|
||||
];
|
||||
}
|
||||
|
||||
private function isBlockedIp(string $ip): bool
|
||||
{
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return true; // could not resolve
|
||||
}
|
||||
foreach (self::BLOCKED_CIDRS as $cidr) {
|
||||
if ($this->ipInCidr($ip, $cidr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ipInCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $cidr) + [1 => 32];
|
||||
|
||||
// IPv6
|
||||
if (str_contains($cidr, ':')) {
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
return false;
|
||||
}
|
||||
$ipBin = inet_pton($ip);
|
||||
$subnetBin = inet_pton($subnet);
|
||||
if ($ipBin === false || $subnetBin === false) {
|
||||
return false;
|
||||
}
|
||||
$bits = (int) $bits;
|
||||
$mask = str_repeat("\xff", (int) ($bits / 8));
|
||||
$remain = $bits % 8;
|
||||
if ($remain) {
|
||||
$mask .= chr(0xff << (8 - $remain));
|
||||
}
|
||||
$mask = str_pad($mask, strlen($subnetBin), "\x00");
|
||||
return ($ipBin & $mask) === ($subnetBin & $mask);
|
||||
}
|
||||
|
||||
// IPv4
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$maskLong = $bits == 32 ? -1 : ~((1 << (32 - (int) $bits)) - 1);
|
||||
return ($ipLong & $maskLong) === ($subnetLong & $maskLong);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
$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')) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
33
app/Http/Controllers/Api/Messaging/PresenceController.php
Normal file
33
app/Http/Controllers/Api/Messaging/PresenceController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Http/Controllers/Api/NotificationController.php
Normal file
37
app/Http/Controllers/Api/NotificationController.php
Normal 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.']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
161
app/Http/Controllers/Api/NovaCards/NovaCardDraftController.php
Normal file
161
app/Http/Controllers/Api/NovaCards/NovaCardDraftController.php
Normal 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
Reference in New Issue
Block a user