Compare commits
19 Commits
f6772f673b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| cab4fbd83e | |||
| 0b25d9570a | |||
| 73260e7eae | |||
| 2608be7420 | |||
| e8b5edf5d2 | |||
| 60f78e8235 | |||
| 979e011257 | |||
| 29c3ff8572 | |||
| 1a62fcb81d | |||
| 7da0fd39f7 | |||
| 7b37259a2c | |||
| 2119741ba7 | |||
| 2728644477 | |||
| b3fc889452 | |||
| 980a15f66e | |||
| 78151aabfe | |||
| 4f576ceb04 | |||
| 547215cbe8 | |||
| 23b813bbff |
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
|
||||||
74
.env.example
74
.env.example
@@ -41,9 +41,39 @@ SESSION_ENCRYPT=false
|
|||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=reverb
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=redis
|
||||||
|
|
||||||
|
MESSAGING_REALTIME=true
|
||||||
|
MESSAGING_BROADCAST_QUEUE=broadcasts
|
||||||
|
MESSAGING_TYPING_TTL=8
|
||||||
|
MESSAGING_TYPING_CACHE_STORE=redis
|
||||||
|
MESSAGING_PRESENCE_TTL=90
|
||||||
|
MESSAGING_CONVERSATION_PRESENCE_TTL=45
|
||||||
|
MESSAGING_PRESENCE_CACHE_STORE=redis
|
||||||
|
MESSAGING_RECOVERY_MAX_MESSAGES=100
|
||||||
|
MESSAGING_OFFLINE_FALLBACK_ONLY=true
|
||||||
|
|
||||||
|
HORIZON_NAME=skinbase-nova
|
||||||
|
HORIZON_PATH=horizon
|
||||||
|
HORIZON_PREFIX=skinbase_nova_horizon:
|
||||||
|
|
||||||
|
REVERB_APP_ID=skinbase-local
|
||||||
|
REVERB_APP_KEY=skinbase-local-key
|
||||||
|
REVERB_APP_SECRET=skinbase-local-secret
|
||||||
|
REVERB_HOST=127.0.0.1
|
||||||
|
REVERB_PORT=8080
|
||||||
|
REVERB_SCHEME=http
|
||||||
|
REVERB_SERVER_HOST=0.0.0.0
|
||||||
|
REVERB_SERVER_PORT=8080
|
||||||
|
REVERB_SERVER_PATH=
|
||||||
|
REVERB_SCALING_ENABLED=false
|
||||||
|
|
||||||
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||||
|
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||||
|
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||||
|
|
||||||
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
|
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
|
||||||
SKINBASE_UPLOADS_V2=false
|
SKINBASE_UPLOADS_V2=false
|
||||||
@@ -57,6 +87,21 @@ SKINBASE_DUPLICATE_HASH_POLICY=block
|
|||||||
VISION_ENABLED=true
|
VISION_ENABLED=true
|
||||||
VISION_QUEUE=default
|
VISION_QUEUE=default
|
||||||
VISION_IMAGE_VARIANT=md
|
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 service (set base URL to enable CLIP calls)
|
||||||
CLIP_BASE_URL=
|
CLIP_BASE_URL=
|
||||||
@@ -81,6 +126,8 @@ RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
|
|||||||
RECOMMENDATIONS_MIN_DIM=64
|
RECOMMENDATIONS_MIN_DIM=64
|
||||||
RECOMMENDATIONS_MAX_DIM=4096
|
RECOMMENDATIONS_MAX_DIM=4096
|
||||||
RECOMMENDATIONS_BACKFILL_BATCH=200
|
RECOMMENDATIONS_BACKFILL_BATCH=200
|
||||||
|
SIMILARITY_VECTOR_ENABLED=false
|
||||||
|
SIMILARITY_VECTOR_ADAPTER=pgvector
|
||||||
|
|
||||||
# Personalized discovery foundation (Phase 8)
|
# Personalized discovery foundation (Phase 8)
|
||||||
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
|
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
|
||||||
@@ -94,6 +141,16 @@ DISCOVERY_WEIGHT_CLICK=2
|
|||||||
DISCOVERY_WEIGHT_FAVORITE=4
|
DISCOVERY_WEIGHT_FAVORITE=4
|
||||||
DISCOVERY_WEIGHT_DOWNLOAD=3
|
DISCOVERY_WEIGHT_DOWNLOAD=3
|
||||||
DISCOVERY_CACHE_TTL_MINUTES=60
|
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_WEIGHTS_VERSION=rank-w-v1
|
||||||
DISCOVERY_RANKING_W1=0.65
|
DISCOVERY_RANKING_W1=0.65
|
||||||
DISCOVERY_RANKING_W2=0.20
|
DISCOVERY_RANKING_W2=0.20
|
||||||
@@ -145,6 +202,9 @@ YOLO_PHOTOGRAPHY_ONLY=true
|
|||||||
# VISION_ENABLED=true
|
# VISION_ENABLED=true
|
||||||
# VISION_QUEUE=vision
|
# VISION_QUEUE=vision
|
||||||
# VISION_IMAGE_VARIANT=md
|
# 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_BASE_URL=https://clip.internal
|
||||||
# CLIP_ANALYZE_ENDPOINT=/analyze
|
# CLIP_ANALYZE_ENDPOINT=/analyze
|
||||||
@@ -174,6 +234,16 @@ YOLO_PHOTOGRAPHY_ONLY=true
|
|||||||
# DISCOVERY_WEIGHT_CLICK=2
|
# DISCOVERY_WEIGHT_CLICK=2
|
||||||
# DISCOVERY_WEIGHT_FAVORITE=4
|
# DISCOVERY_WEIGHT_FAVORITE=4
|
||||||
# DISCOVERY_WEIGHT_DOWNLOAD=3
|
# 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_WEIGHTS_VERSION=rank-w-v1
|
||||||
# DISCOVERY_RANKING_W1=0.65
|
# DISCOVERY_RANKING_W1=0.65
|
||||||
# DISCOVERY_RANKING_W2=0.20
|
# DISCOVERY_RANKING_W2=0.20
|
||||||
|
|||||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -19,9 +19,37 @@
|
|||||||
/public/files
|
/public/files
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/storage/app
|
||||||
|
/storage/framework/cache
|
||||||
|
/storage/framework/sessions
|
||||||
|
/storage/framework/views
|
||||||
|
/storage/logs
|
||||||
|
/storage/testing
|
||||||
|
/storage/*.log
|
||||||
|
/storage/*.key
|
||||||
|
/storage/*.sqlite
|
||||||
|
/storage/*.sqlite3
|
||||||
|
/storage/*.zip
|
||||||
|
/storage/*.tar.gz
|
||||||
|
/storage/*.tar.gz
|
||||||
|
/storage/*.tar.bz2
|
||||||
|
/storage/*.tar.xz
|
||||||
|
/storage/*.tar
|
||||||
|
/storage/*.tgz
|
||||||
|
/storage/*.tbz2
|
||||||
|
/storage/*.txz
|
||||||
|
/storage/*.zip
|
||||||
|
/storage/*.tar.gz
|
||||||
|
/storage/*.tar.bz2
|
||||||
|
/storage/*.tar.xz
|
||||||
|
/storage/*.tar
|
||||||
|
/storage/*.tgz
|
||||||
/vendor
|
/vendor
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/oldSite/*
|
/oldSite/*
|
||||||
oldSite
|
oldSite
|
||||||
|
packages
|
||||||
|
/packages/*
|
||||||
|
/public/admin/*
|
||||||
|
|||||||
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">';
|
echo '<div class="row well">';
|
||||||
if (!empty($_SESSION['web_login']['status'])) {
|
if (!empty($_SESSION['web_login']['status'])) {
|
||||||
echo '<form action="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '" method="post">';
|
echo '<form action="' . htmlspecialchars(route('community.chat'), ENT_QUOTES, 'UTF-8') . '" method="post">';
|
||||||
|
echo csrf_field();
|
||||||
echo '<div class="col-sm-10">';
|
echo '<div class="col-sm-10">';
|
||||||
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
|
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,21 +47,73 @@ final class AiTagArtworksCommand extends Command
|
|||||||
// Prompt
|
// Prompt
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
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;
|
PROMPT;
|
||||||
|
|
||||||
private const USER_PROMPT = <<<'PROMPT'
|
private const USER_PROMPT = <<<'PROMPT'
|
||||||
Analyse the artwork image and return a JSON array of relevant tags.
|
Analyse this artwork image and return a JSON array of relevant tags.
|
||||||
Cover: art style, subject/theme, dominant colours, mood, technique, and medium where visible.
|
|
||||||
|
|
||||||
Rules:
|
Requirements:
|
||||||
- Return ONLY a valid JSON array of lowercase strings — no markdown, no explanation.
|
- Return ONLY a valid JSON array of lowercase strings.
|
||||||
- Each tag must be 1–4 words, no punctuation except hyphens.
|
- No markdown, no explanation, no extra text.
|
||||||
- Between 6 and 12 tags total.
|
- 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:
|
Focus on tags from these groups when visible:
|
||||||
["digital painting","fantasy","portrait","dark tones","glowing eyes","detailed","dramatic lighting"]
|
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;
|
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'])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ class AvatarsMigrate extends Command
|
|||||||
*
|
*
|
||||||
* @var int[]
|
* @var int[]
|
||||||
*/
|
*/
|
||||||
protected $sizes = [32, 40, 64, 128, 256, 512];
|
protected $sizes = [32, 40, 64, 80, 96, 128, 256, 512];
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
|
|||||||
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
|
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 $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
|
||||||
|
|
||||||
protected string $migrationLogPath;
|
protected string $migrationLogPath;
|
||||||
@@ -20,7 +20,7 @@ class ImportLegacyUsers extends Command
|
|||||||
|
|
||||||
public function handle(): int
|
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);
|
@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.
|
// 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;
|
$legacyId = (int) $row->user_id;
|
||||||
|
|
||||||
// Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB).
|
// Use legacy username as-is by default. Placeholder tmp usernames can be
|
||||||
$username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
|
// 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 ?? ''));
|
$normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
||||||
if ($normalizedLegacy !== $username) {
|
if ($normalizedLegacy !== $username) {
|
||||||
@@ -173,7 +179,12 @@ class ImportLegacyUsers extends Command
|
|||||||
|
|
||||||
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
||||||
$now = now();
|
$now = now();
|
||||||
$alreadyExists = DB::table('users')->where('id', $legacyId)->exists();
|
$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
|
// All fields synced from legacy on every run
|
||||||
$sharedFields = [
|
$sharedFields = [
|
||||||
@@ -212,7 +223,7 @@ class ImportLegacyUsers extends Command
|
|||||||
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
||||||
'language' => $row->lang ?: null,
|
'language' => $row->lang ?: null,
|
||||||
'birthdate' => $row->birth ?: null,
|
'birthdate' => $row->birth ?: null,
|
||||||
'gender' => $row->gender ?: 'X',
|
'gender' => $this->normalizeLegacyGender($row->gender ?? null),
|
||||||
'website' => $row->web ?: null,
|
'website' => $row->web ?: null,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]
|
]
|
||||||
@@ -232,7 +243,7 @@ class ImportLegacyUsers extends Command
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (Schema::hasTable('username_redirects')) {
|
if (Schema::hasTable('username_redirects')) {
|
||||||
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
$old = $this->usernameRedirectKey((string) ($row->uname ?? ''));
|
||||||
if ($old !== '' && $old !== $username) {
|
if ($old !== '' && $old !== $username) {
|
||||||
DB::table('username_redirects')->updateOrInsert(
|
DB::table('username_redirects')->updateOrInsert(
|
||||||
['old_username' => $old],
|
['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.
|
* Ensure statistic values are safe for unsigned DB columns.
|
||||||
*/
|
*/
|
||||||
@@ -265,6 +316,24 @@ class ImportLegacyUsers extends Command
|
|||||||
return UsernamePolicy::sanitizeLegacy($username);
|
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
|
protected function sanitizeEmailLocal(string $value): string
|
||||||
{
|
{
|
||||||
$local = strtolower(trim($value));
|
$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 : '{}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ActivityEvent;
|
use App\Models\ActivityEvent;
|
||||||
|
use App\Services\Activity\UserActivityService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -79,7 +80,7 @@ class PublishScheduledArtworksCommand extends Command
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$artwork->is_public = true;
|
$artwork->is_public = $artwork->visibility !== Artwork::VISIBILITY_PRIVATE;
|
||||||
$artwork->published_at = $now;
|
$artwork->published_at = $now;
|
||||||
$artwork->artwork_status = 'published';
|
$artwork->artwork_status = 'published';
|
||||||
$artwork->save();
|
$artwork->save();
|
||||||
@@ -103,6 +104,10 @@ class PublishScheduledArtworksCommand extends Command
|
|||||||
);
|
);
|
||||||
} catch (\Throwable) {}
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(UserActivityService::class)->logUpload((int) $artwork->user_id, (int) $artwork->id);
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
|
||||||
$published++;
|
$published++;
|
||||||
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
|
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ use App\Services\TrendingService;
|
|||||||
use Illuminate\Console\Command;
|
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
|
class RecalculateTrendingCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'skinbase:recalculate-trending
|
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}
|
{--chunk=1000 : DB chunk size}
|
||||||
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
|
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
|
||||||
|
|
||||||
@@ -30,11 +30,11 @@ class RecalculateTrendingCommand extends Command
|
|||||||
$chunkSize = (int) $this->option('chunk');
|
$chunkSize = (int) $this->option('chunk');
|
||||||
$skipIndex = (bool) $this->option('skip-index');
|
$skipIndex = (bool) $this->option('skip-index');
|
||||||
|
|
||||||
$periods = $period === 'all' ? ['24h', '7d'] : [$period];
|
$periods = $period === 'all' ? ['1h', '24h', '7d'] : [$period];
|
||||||
|
|
||||||
foreach ($periods as $p) {
|
foreach ($periods as $p) {
|
||||||
if (! in_array($p, ['24h', '7d'], true)) {
|
if (! in_array($p, ['1h', '24h', '7d'], true)) {
|
||||||
$this->error("Invalid period '{$p}'. Use 24h, 7d, or all.");
|
$this->error("Invalid period '{$p}'. Use 1h, 24h, 7d, or all.");
|
||||||
return self::FAILURE;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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(),
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,19 +7,30 @@ use App\Console\Commands\ImportLegacyUsers;
|
|||||||
use App\Console\Commands\ImportCategories;
|
use App\Console\Commands\ImportCategories;
|
||||||
use App\Console\Commands\MigrateFeaturedWorks;
|
use App\Console\Commands\MigrateFeaturedWorks;
|
||||||
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
use App\Console\Commands\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\AggregateSimilarArtworkAnalyticsCommand;
|
||||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||||
|
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||||
|
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
||||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||||
use App\Console\Commands\AiTagArtworksCommand;
|
use App\Console\Commands\AiTagArtworksCommand;
|
||||||
|
use App\Console\Commands\SyncCountriesCommand;
|
||||||
use App\Console\Commands\CompareFeedAbCommand;
|
use App\Console\Commands\CompareFeedAbCommand;
|
||||||
|
use App\Console\Commands\DispatchCollectionMaintenanceCommand;
|
||||||
use App\Console\Commands\RecalculateTrendingCommand;
|
use App\Console\Commands\RecalculateTrendingCommand;
|
||||||
use App\Console\Commands\RecalculateRankingsCommand;
|
use App\Console\Commands\RecalculateRankingsCommand;
|
||||||
use App\Console\Commands\MetricsSnapshotHourlyCommand;
|
use App\Console\Commands\MetricsSnapshotHourlyCommand;
|
||||||
use App\Console\Commands\RecalculateHeatCommand;
|
use App\Console\Commands\RecalculateHeatCommand;
|
||||||
|
use App\Jobs\UpdateLeaderboardsJob;
|
||||||
|
use App\Jobs\RebuildTrendingNovaCardsJob;
|
||||||
|
use App\Jobs\RecalculateRisingNovaCardsJob;
|
||||||
use App\Jobs\RankComputeArtworkScoresJob;
|
use App\Jobs\RankComputeArtworkScoresJob;
|
||||||
use App\Jobs\RankBuildListsJob;
|
use App\Jobs\RankBuildListsJob;
|
||||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||||
use App\Console\Commands\PublishScheduledArtworksCommand;
|
use App\Console\Commands\PublishScheduledArtworksCommand;
|
||||||
|
use App\Console\Commands\SyncCollectionLifecycleCommand;
|
||||||
|
|
||||||
class Kernel extends ConsoleKernel
|
class Kernel extends ConsoleKernel
|
||||||
{
|
{
|
||||||
@@ -38,12 +49,20 @@ class Kernel extends ConsoleKernel
|
|||||||
\App\Console\Commands\ResetAllUserPasswords::class,
|
\App\Console\Commands\ResetAllUserPasswords::class,
|
||||||
CleanupUploadsCommand::class,
|
CleanupUploadsCommand::class,
|
||||||
PublishScheduledArtworksCommand::class,
|
PublishScheduledArtworksCommand::class,
|
||||||
|
SyncCollectionLifecycleCommand::class,
|
||||||
|
DispatchCollectionMaintenanceCommand::class,
|
||||||
BackfillArtworkEmbeddingsCommand::class,
|
BackfillArtworkEmbeddingsCommand::class,
|
||||||
|
BackfillArtworkVectorIndexCommand::class,
|
||||||
|
IndexArtworkVectorsCommand::class,
|
||||||
|
SearchArtworkVectorsCommand::class,
|
||||||
AggregateSimilarArtworkAnalyticsCommand::class,
|
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||||
AggregateFeedAnalyticsCommand::class,
|
AggregateFeedAnalyticsCommand::class,
|
||||||
|
AggregateTagInteractionAnalyticsCommand::class,
|
||||||
|
SeedTagInteractionDemoCommand::class,
|
||||||
EvaluateFeedWeightsCommand::class,
|
EvaluateFeedWeightsCommand::class,
|
||||||
CompareFeedAbCommand::class,
|
CompareFeedAbCommand::class,
|
||||||
AiTagArtworksCommand::class,
|
AiTagArtworksCommand::class,
|
||||||
|
SyncCountriesCommand::class,
|
||||||
\App\Console\Commands\MigrateFollows::class,
|
\App\Console\Commands\MigrateFollows::class,
|
||||||
RecalculateTrendingCommand::class,
|
RecalculateTrendingCommand::class,
|
||||||
RecalculateRankingsCommand::class,
|
RecalculateRankingsCommand::class,
|
||||||
@@ -64,8 +83,19 @@ class Kernel extends ConsoleKernel
|
|||||||
->name('publish-scheduled-artworks')
|
->name('publish-scheduled-artworks')
|
||||||
->withoutOverlapping(2) // prevent overlap up to 2 minutes
|
->withoutOverlapping(2) // prevent overlap up to 2 minutes
|
||||||
->runInBackground();
|
->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-similar-artworks')->dailyAt('03:10');
|
||||||
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
$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)
|
// 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=24h')->everyThirtyMinutes();
|
||||||
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
|
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
|
||||||
@@ -83,6 +113,18 @@ class Kernel extends ConsoleKernel
|
|||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->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) ─────────────────────────────────
|
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
|
||||||
// Step 1: snapshot metric totals every hour at :00
|
// Step 1: snapshot metric totals every hour at :00
|
||||||
$schedule->command('nova:metrics-snapshot-hourly')
|
$schedule->command('nova:metrics-snapshot-hourly')
|
||||||
@@ -96,9 +138,21 @@ class Kernel extends ConsoleKernel
|
|||||||
->name('recalculate-heat')
|
->name('recalculate-heat')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->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
|
// Step 3: prune old snapshots daily at 04:00
|
||||||
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
|
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
|
||||||
->dailyAt('04:00');
|
->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) {}
|
||||||
|
}
|
||||||
@@ -2,15 +2,46 @@
|
|||||||
|
|
||||||
namespace App\Events;
|
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\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
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 function __construct(
|
||||||
public int $conversationId,
|
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;
|
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\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
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 function __construct(
|
||||||
public int $conversationId,
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Services\EarlyGrowth\ActivityLayer;
|
|
||||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
|
||||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EarlyGrowthAdminController (§14)
|
|
||||||
*
|
|
||||||
* Admin panel for the Early-Stage Growth System.
|
|
||||||
* All toggles are ENV-driven; updating .env requires a deploy.
|
|
||||||
* This panel provides a read-only status view plus a cache-flush action.
|
|
||||||
*
|
|
||||||
* Future v2: wire to a `settings` DB table so admins can toggle without
|
|
||||||
* a deploy. The EarlyGrowth::enabled() contract already supports this.
|
|
||||||
*/
|
|
||||||
final class EarlyGrowthAdminController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AdaptiveTimeWindow $timeWindow,
|
|
||||||
private readonly ActivityLayer $activityLayer,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /admin/early-growth
|
|
||||||
* Status dashboard: shows current config, live stats, toggle instructions.
|
|
||||||
*/
|
|
||||||
public function index(): View
|
|
||||||
{
|
|
||||||
$uploadsPerDay = $this->timeWindow->getUploadsPerDay();
|
|
||||||
|
|
||||||
return view('admin.early-growth.index', [
|
|
||||||
'status' => EarlyGrowth::status(),
|
|
||||||
'mode' => EarlyGrowth::mode(),
|
|
||||||
'uploads_per_day' => $uploadsPerDay,
|
|
||||||
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
|
|
||||||
'activity' => $this->activityLayer->getSignals(),
|
|
||||||
'cache_keys' => [
|
|
||||||
'egs.uploads_per_day',
|
|
||||||
'egs.auto_disable_check',
|
|
||||||
'egs.spotlight.*',
|
|
||||||
'egs.curated.*',
|
|
||||||
'egs.grid_filler.*',
|
|
||||||
'egs.activity_signals',
|
|
||||||
'homepage.fresh.*',
|
|
||||||
'discover.trending.*',
|
|
||||||
'discover.rising.*',
|
|
||||||
],
|
|
||||||
'env_toggles' => [
|
|
||||||
['key' => 'NOVA_EARLY_GROWTH_ENABLED', 'current' => env('NOVA_EARLY_GROWTH_ENABLED', 'false')],
|
|
||||||
['key' => 'NOVA_EARLY_GROWTH_MODE', 'current' => env('NOVA_EARLY_GROWTH_MODE', 'off')],
|
|
||||||
['key' => 'NOVA_EGS_ADAPTIVE_WINDOW', 'current' => env('NOVA_EGS_ADAPTIVE_WINDOW', 'true')],
|
|
||||||
['key' => 'NOVA_EGS_GRID_FILLER', 'current' => env('NOVA_EGS_GRID_FILLER', 'true')],
|
|
||||||
['key' => 'NOVA_EGS_SPOTLIGHT', 'current' => env('NOVA_EGS_SPOTLIGHT', 'true')],
|
|
||||||
['key' => 'NOVA_EGS_ACTIVITY_LAYER', 'current' => env('NOVA_EGS_ACTIVITY_LAYER', 'false')],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /admin/early-growth/cache
|
|
||||||
* Flush all EGS-related cache keys so new config changes take effect immediately.
|
|
||||||
*/
|
|
||||||
public function flushCache(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$keys = [
|
|
||||||
'egs.uploads_per_day',
|
|
||||||
'egs.auto_disable_check',
|
|
||||||
'egs.activity_signals',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Flush the EGS daily spotlight caches for today
|
|
||||||
$today = now()->format('Y-m-d');
|
|
||||||
foreach ([6, 12, 18, 24] as $n) {
|
|
||||||
Cache::forget("egs.spotlight.{$today}.{$n}");
|
|
||||||
Cache::forget("egs.curated.{$today}.{$n}.7");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush fresh/trending homepage sections
|
|
||||||
foreach ([6, 8, 10, 12] as $limit) {
|
|
||||||
foreach (['off', 'light', 'aggressive'] as $mode) {
|
|
||||||
Cache::forget("homepage.fresh.{$limit}.egs-{$mode}");
|
|
||||||
Cache::forget("homepage.fresh.{$limit}.std");
|
|
||||||
}
|
|
||||||
Cache::forget("homepage.trending.{$limit}");
|
|
||||||
Cache::forget("homepage.rising.{$limit}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush key keys
|
|
||||||
foreach ($keys as $key) {
|
|
||||||
Cache::forget($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('admin.early-growth.index')
|
|
||||||
->with('success', 'Early Growth System cache flushed. Changes will take effect on next page load.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /admin/early-growth/status (JSON — for monitoring/healthcheck)
|
|
||||||
*/
|
|
||||||
public function status(): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'egs' => EarlyGrowth::status(),
|
|
||||||
'uploads_per_day' => $this->timeWindow->getUploadsPerDay(),
|
|
||||||
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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;
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\NovaCard;
|
||||||
use App\Models\Report;
|
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\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
final class ModerationReportQueueController extends Controller
|
final class ModerationReportQueueController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ReportTargetResolver $targets,
|
||||||
|
private readonly NovaCardPublishModerationService $moderation,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$status = (string) $request->query('status', 'open');
|
$status = (string) $request->query('status', 'open');
|
||||||
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
|
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
|
||||||
|
$group = (string) $request->query('group', '');
|
||||||
|
|
||||||
$items = Report::query()
|
$query = Report::query()
|
||||||
->with('reporter:id,username')
|
->with(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username'])
|
||||||
->where('status', $status)
|
->where('status', $status)
|
||||||
->orderByDesc('id')
|
->orderByDesc('id');
|
||||||
->paginate(30);
|
|
||||||
|
|
||||||
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\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
final class UsernameApprovalController extends Controller
|
final class UsernameApprovalController extends Controller
|
||||||
@@ -124,6 +125,9 @@ final class UsernameApprovalController extends Controller
|
|||||||
|
|
||||||
$user->username = $requested;
|
$user->username = $requested;
|
||||||
$user->username_changed_at = now();
|
$user->username_changed_at = now();
|
||||||
|
if (Schema::hasColumn('users', 'last_username_change_at')) {
|
||||||
|
$user->last_username_change_at = now();
|
||||||
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
if ($old !== '') {
|
if ($old !== '') {
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Activity\UserActivityService;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ArtworkComment;
|
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\ContentSanitizer;
|
||||||
use App\Services\LegacySmileyMapper;
|
|
||||||
use App\Support\AvatarUrl;
|
use App\Support\AvatarUrl;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -113,6 +117,7 @@ class ArtworkCommentController extends Controller
|
|||||||
Cache::forget('comments.latest.all.page1');
|
Cache::forget('comments.latest.all.page1');
|
||||||
|
|
||||||
$comment->load(['user', 'user.profile']);
|
$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)
|
// Record activity event (fire-and-forget; never break the response)
|
||||||
try {
|
try {
|
||||||
@@ -124,6 +129,15 @@ class ArtworkCommentController extends Controller
|
|||||||
);
|
);
|
||||||
} catch (\Throwable) {}
|
} 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);
|
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +207,7 @@ class ArtworkCommentController extends Controller
|
|||||||
'id' => $c->id,
|
'id' => $c->id,
|
||||||
'parent_id' => $c->parent_id,
|
'parent_id' => $c->parent_id,
|
||||||
'raw_content' => $c->raw_content ?? $c->content,
|
'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(),
|
'created_at' => $c->created_at?->toIso8601String(),
|
||||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||||
'can_edit' => $currentUserId === $userId,
|
'can_edit' => $currentUserId === $userId,
|
||||||
@@ -204,6 +218,8 @@ class ArtworkCommentController extends Controller
|
|||||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
'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;
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ class ArtworkController extends Controller
|
|||||||
(int) $user->id,
|
(int) $user->id,
|
||||||
(string) $data['title'],
|
(string) $data['title'],
|
||||||
isset($data['description']) ? (string) $data['description'] : null,
|
isset($data['description']) ? (string) $data['description'] : null,
|
||||||
$categoryId
|
$categoryId,
|
||||||
|
(bool) ($data['is_mature'] ?? false)
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ final class ArtworkDownloadController extends Controller
|
|||||||
'artwork_id' => $artwork->id,
|
'artwork_id' => $artwork->id,
|
||||||
'user_id' => $request->user()?->id,
|
'user_id' => $request->user()?->id,
|
||||||
'ip' => $bin !== false ? $bin : null,
|
'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(),
|
'created_at' => now(),
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Events\Achievements\AchievementCheckRequested;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Notifications\ArtworkLikedNotification;
|
||||||
use App\Services\FollowService;
|
use App\Services\FollowService;
|
||||||
|
use App\Services\Activity\UserActivityService;
|
||||||
use App\Services\UserStatsService;
|
use App\Services\UserStatsService;
|
||||||
|
use App\Services\XPService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -14,11 +19,25 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
|
|
||||||
final class ArtworkInteractionController extends Controller
|
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
|
public function favorite(Request $request, int $artworkId): JsonResponse
|
||||||
{
|
{
|
||||||
$state = $request->boolean('state', true);
|
$state = $request->boolean('state', true);
|
||||||
|
|
||||||
$this->toggleSimple(
|
$changed = $this->toggleSimple(
|
||||||
request: $request,
|
request: $request,
|
||||||
table: 'artwork_favourites',
|
table: 'artwork_favourites',
|
||||||
keyColumns: ['user_id', 'artwork_id'],
|
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');
|
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||||
if ($creatorId) {
|
if ($creatorId) {
|
||||||
$svc = app(UserStatsService::class);
|
$svc = app(UserStatsService::class);
|
||||||
if ($state) {
|
if ($state && $changed) {
|
||||||
$svc->incrementFavoritesReceived($creatorId);
|
$svc->incrementFavoritesReceived($creatorId);
|
||||||
$svc->setLastActiveAt((int) $request->user()->id);
|
$svc->setLastActiveAt((int) $request->user()->id);
|
||||||
|
|
||||||
@@ -46,7 +65,11 @@ final class ArtworkInteractionController extends Controller
|
|||||||
targetId: $artworkId,
|
targetId: $artworkId,
|
||||||
);
|
);
|
||||||
} catch (\Throwable) {}
|
} catch (\Throwable) {}
|
||||||
} else {
|
|
||||||
|
try {
|
||||||
|
app(UserActivityService::class)->logFavourite((int) $request->user()->id, $artworkId);
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
} elseif (! $state && $changed) {
|
||||||
$svc->decrementFavoritesReceived($creatorId);
|
$svc->decrementFavoritesReceived($creatorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +79,7 @@ final class ArtworkInteractionController extends Controller
|
|||||||
|
|
||||||
public function like(Request $request, int $artworkId): JsonResponse
|
public function like(Request $request, int $artworkId): JsonResponse
|
||||||
{
|
{
|
||||||
$this->toggleSimple(
|
$changed = $this->toggleSimple(
|
||||||
request: $request,
|
request: $request,
|
||||||
table: 'artwork_likes',
|
table: 'artwork_likes',
|
||||||
keyColumns: ['user_id', 'artwork_id'],
|
keyColumns: ['user_id', 'artwork_id'],
|
||||||
@@ -67,6 +90,23 @@ final class ArtworkInteractionController extends Controller
|
|||||||
|
|
||||||
$this->syncArtworkStats($artworkId);
|
$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));
|
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +145,9 @@ final class ArtworkInteractionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$svc = app(FollowService::class);
|
$svc = app(FollowService::class);
|
||||||
$state = $request->boolean('state', true);
|
$state = $request->has('state')
|
||||||
|
? $request->boolean('state')
|
||||||
|
: ! $request->isMethod('delete');
|
||||||
|
|
||||||
if ($state) {
|
if ($state) {
|
||||||
$svc->follow($actorId, $userId);
|
$svc->follow($actorId, $userId);
|
||||||
@@ -148,7 +190,7 @@ final class ArtworkInteractionController extends Controller
|
|||||||
array $keyValues,
|
array $keyValues,
|
||||||
array $insertPayload,
|
array $insertPayload,
|
||||||
string $requiredTable
|
string $requiredTable
|
||||||
): void {
|
): bool {
|
||||||
if (! Schema::hasTable($requiredTable)) {
|
if (! Schema::hasTable($requiredTable)) {
|
||||||
abort(422, 'Interaction unavailable');
|
abort(422, 'Interaction unavailable');
|
||||||
}
|
}
|
||||||
@@ -163,10 +205,13 @@ final class ArtworkInteractionController extends Controller
|
|||||||
if ($state) {
|
if ($state) {
|
||||||
if (! $query->exists()) {
|
if (! $query->exists()) {
|
||||||
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
|
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$query->delete();
|
return $query->delete() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncArtworkStats(int $artworkId): void
|
private function syncArtworkStats(int $artworkId): void
|
||||||
@@ -194,6 +239,10 @@ final class ArtworkInteractionController extends Controller
|
|||||||
|
|
||||||
private function statusPayload(int $viewerId, int $artworkId): array
|
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')
|
$isFavorited = Schema::hasTable('artwork_favourites')
|
||||||
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||||
: false;
|
: false;
|
||||||
@@ -206,15 +255,21 @@ final class ArtworkInteractionController extends Controller
|
|||||||
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
|
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
$bookmarks = Schema::hasTable('artwork_bookmarks')
|
||||||
|
? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count()
|
||||||
|
: 0;
|
||||||
|
|
||||||
$likes = Schema::hasTable('artwork_likes')
|
$likes = Schema::hasTable('artwork_likes')
|
||||||
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
|
'is_bookmarked' => $isBookmarked,
|
||||||
'is_favorited' => $isFavorited,
|
'is_favorited' => $isFavorited,
|
||||||
'is_liked' => $isLiked,
|
'is_liked' => $isLiked,
|
||||||
'stats' => [
|
'stats' => [
|
||||||
|
'bookmarks' => $bookmarks,
|
||||||
'favorites' => $favorites,
|
'favorites' => $favorites,
|
||||||
'likes' => $likes,
|
'likes' => $likes,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Services\ArtworkStatsService;
|
use App\Services\ArtworkStatsService;
|
||||||
|
use App\Services\XPService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -26,7 +27,10 @@ use Illuminate\Http\Request;
|
|||||||
*/
|
*/
|
||||||
final class ArtworkViewController extends Controller
|
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
|
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.
|
// Defer to Redis when available, fall back to direct DB increment.
|
||||||
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
|
$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.
|
// Mark this session so the artwork is not counted again.
|
||||||
if ($request->hasSession()) {
|
if ($request->hasSession()) {
|
||||||
$request->session()->put($sessionKey, true);
|
$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([
|
$payload = $request->validate([
|
||||||
'event_id' => ['nullable', 'uuid'],
|
'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'],
|
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||||
'occurred_at' => ['nullable', 'date'],
|
'occurred_at' => ['nullable', 'date'],
|
||||||
'algo_version' => ['nullable', 'string', 'max:64'],
|
'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;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\Recommendations\PersonalizedFeedService;
|
use App\Services\Recommendations\RecommendationFeedResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
final class FeedController extends Controller
|
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'],
|
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$result = $this->feedService->getFeed(
|
$result = $this->feedResolver->getFeed(
|
||||||
userId: (int) $request->user()->id,
|
userId: (int) $request->user()->id,
|
||||||
limit: isset($payload['limit']) ? (int) $payload['limit'] : 24,
|
limit: isset($payload['limit']) ? (int) $payload['limit'] : 24,
|
||||||
cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null,
|
cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null,
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ final class FollowController extends Controller
|
|||||||
return response()->json([
|
return response()->json([
|
||||||
'following' => true,
|
'following' => true,
|
||||||
'followers_count' => $this->followService->followersCount((int) $target->id),
|
'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([
|
return response()->json([
|
||||||
'following' => false,
|
'following' => false,
|
||||||
'followers_count' => $this->followService->followersCount((int) $target->id),
|
'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'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Messaging;
|
namespace App\Http\Controllers\Api\Messaging;
|
||||||
|
|
||||||
|
use App\Events\ConversationUpdated;
|
||||||
use App\Http\Controllers\Controller;
|
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\Conversation;
|
||||||
use App\Models\ConversationParticipant;
|
use App\Models\ConversationParticipant;
|
||||||
use App\Models\Message;
|
|
||||||
use App\Models\User;
|
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\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@@ -16,6 +22,13 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
|
|
||||||
class ConversationController extends Controller
|
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 ─────────────────────────────────────
|
// ── GET /api/messages/conversations ─────────────────────────────────────
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
@@ -26,24 +39,14 @@ class ConversationController extends Controller
|
|||||||
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
|
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
|
||||||
|
|
||||||
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
|
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
|
||||||
return Conversation::query()
|
$query = Conversation::query()
|
||||||
->select('conversations.*')
|
->select('conversations.*')
|
||||||
->join('conversation_participants as cp_me', function ($join) use ($user) {
|
->join('conversation_participants as cp_me', function ($join) use ($user) {
|
||||||
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
||||||
->where('cp_me.user_id', '=', $user->id)
|
->where('cp_me.user_id', '=', $user->id)
|
||||||
->whereNull('cp_me.left_at');
|
->whereNull('cp_me.left_at');
|
||||||
})
|
})
|
||||||
->addSelect([
|
->where('conversations.is_active', true)
|
||||||
'unread_count' => Message::query()
|
|
||||||
->selectRaw('count(*)')
|
|
||||||
->whereColumn('messages.conversation_id', 'conversations.id')
|
|
||||||
->where('messages.sender_id', '!=', $user->id)
|
|
||||||
->whereNull('messages.deleted_at')
|
|
||||||
->where(function ($query) {
|
|
||||||
$query->whereNull('cp_me.last_read_at')
|
|
||||||
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->with([
|
->with([
|
||||||
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
|
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
|
||||||
'latestMessage.sender:id,username',
|
'latestMessage.sender:id,username',
|
||||||
@@ -51,8 +54,11 @@ class ConversationController extends Controller
|
|||||||
->orderByDesc('cp_me.is_pinned')
|
->orderByDesc('cp_me.is_pinned')
|
||||||
->orderByDesc('cp_me.pinned_at')
|
->orderByDesc('cp_me.pinned_at')
|
||||||
->orderByDesc('last_message_at')
|
->orderByDesc('last_message_at')
|
||||||
->orderByDesc('conversations.id')
|
->orderByDesc('conversations.id');
|
||||||
->paginate(20, ['conversations.*'], 'page', $page);
|
|
||||||
|
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
|
||||||
|
|
||||||
|
return $query->paginate(20, ['conversations.*'], 'page', $page);
|
||||||
});
|
});
|
||||||
|
|
||||||
$conversations->through(function ($conv) use ($user) {
|
$conversations->through(function ($conv) use ($user) {
|
||||||
@@ -61,7 +67,12 @@ class ConversationController extends Controller
|
|||||||
return $conv;
|
return $conv;
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json($conversations);
|
return response()->json([
|
||||||
|
...$conversations->toArray(),
|
||||||
|
'summary' => [
|
||||||
|
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
|
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
|
||||||
@@ -80,18 +91,10 @@ class ConversationController extends Controller
|
|||||||
|
|
||||||
// ── POST /api/messages/conversation ─────────────────────────────────────
|
// ── POST /api/messages/conversation ─────────────────────────────────────
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(StoreConversationRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
$data = $request->validated();
|
||||||
$data = $request->validate([
|
|
||||||
'type' => 'required|in:direct,group',
|
|
||||||
'recipient_id' => 'required_if:type,direct|integer|exists:users,id',
|
|
||||||
'participant_ids' => 'required_if:type,group|array|min:2',
|
|
||||||
'participant_ids.*'=> 'integer|exists:users,id',
|
|
||||||
'title' => 'required_if:type,group|nullable|string|max:120',
|
|
||||||
'body' => 'required|string|max:5000',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($data['type'] === 'direct') {
|
if ($data['type'] === 'direct') {
|
||||||
return $this->createDirect($request, $user, $data);
|
return $this->createDirect($request, $user, $data);
|
||||||
@@ -104,20 +107,29 @@ class ConversationController extends Controller
|
|||||||
|
|
||||||
public function markRead(Request $request, int $id): JsonResponse
|
public function markRead(Request $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$participant = $this->participantRecord($request, $id);
|
$conversation = $this->findAuthorized($request, $id);
|
||||||
$participant->update(['last_read_at' => now()]);
|
$participant = $this->conversationReads->markConversationRead(
|
||||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
$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 ─────────────────────────
|
// ── POST /api/messages/{conversation_id}/archive ─────────────────────────
|
||||||
|
|
||||||
public function archive(Request $request, int $id): JsonResponse
|
public function archive(Request $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
|
$conversation = $this->findAuthorized($request, $id);
|
||||||
$participant = $this->participantRecord($request, $id);
|
$participant = $this->participantRecord($request, $id);
|
||||||
$participant->update(['is_archived' => ! $participant->is_archived]);
|
$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]);
|
return response()->json(['is_archived' => $participant->is_archived]);
|
||||||
}
|
}
|
||||||
@@ -126,27 +138,30 @@ class ConversationController extends Controller
|
|||||||
|
|
||||||
public function mute(Request $request, int $id): JsonResponse
|
public function mute(Request $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
|
$conversation = $this->findAuthorized($request, $id);
|
||||||
$participant = $this->participantRecord($request, $id);
|
$participant = $this->participantRecord($request, $id);
|
||||||
$participant->update(['is_muted' => ! $participant->is_muted]);
|
$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]);
|
return response()->json(['is_muted' => $participant->is_muted]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function pin(Request $request, int $id): JsonResponse
|
public function pin(Request $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
|
$conversation = $this->findAuthorized($request, $id);
|
||||||
$participant = $this->participantRecord($request, $id);
|
$participant = $this->participantRecord($request, $id);
|
||||||
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
|
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
|
||||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
$this->broadcastConversationUpdate($conversation, 'conversation.pinned');
|
||||||
|
|
||||||
return response()->json(['is_pinned' => true]);
|
return response()->json(['is_pinned' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function unpin(Request $request, int $id): JsonResponse
|
public function unpin(Request $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
|
$conversation = $this->findAuthorized($request, $id);
|
||||||
$participant = $this->participantRecord($request, $id);
|
$participant = $this->participantRecord($request, $id);
|
||||||
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
|
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
|
||||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
$this->broadcastConversationUpdate($conversation, 'conversation.unpinned');
|
||||||
|
|
||||||
return response()->json(['is_pinned' => false]);
|
return response()->json(['is_pinned' => false]);
|
||||||
}
|
}
|
||||||
@@ -182,14 +197,15 @@ class ConversationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$participant->update(['left_at' => now()]);
|
$participant->update(['left_at' => now()]);
|
||||||
$this->touchConversationCachesForUsers($participantUserIds);
|
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||||
|
$this->broadcastConversationUpdate($conv, 'conversation.left', $participantUserIds);
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/messages/{conversation_id}/add-user ────────────────────────
|
// ── 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);
|
$conv = $this->findAuthorized($request, $id);
|
||||||
$this->requireAdmin($request, $id);
|
$this->requireAdmin($request, $id);
|
||||||
@@ -198,9 +214,7 @@ class ConversationController extends Controller
|
|||||||
->pluck('user_id')
|
->pluck('user_id')
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validated();
|
||||||
'user_id' => 'required|integer|exists:users,id',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$existing = ConversationParticipant::where('conversation_id', $id)
|
$existing = ConversationParticipant::where('conversation_id', $id)
|
||||||
->where('user_id', $data['user_id'])
|
->where('user_id', $data['user_id'])
|
||||||
@@ -220,20 +234,18 @@ class ConversationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$participantUserIds[] = (int) $data['user_id'];
|
$participantUserIds[] = (int) $data['user_id'];
|
||||||
$this->touchConversationCachesForUsers($participantUserIds);
|
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||||
|
$this->broadcastConversationUpdate($conv, 'conversation.participant_added', $participantUserIds);
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DELETE /api/messages/{conversation_id}/remove-user ───────────────────
|
// ── 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);
|
$this->requireAdmin($request, $id);
|
||||||
|
$data = $request->validated();
|
||||||
$data = $request->validate([
|
|
||||||
'user_id' => 'required|integer',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Cannot remove the conversation creator
|
// Cannot remove the conversation creator
|
||||||
$conv = Conversation::findOrFail($id);
|
$conv = Conversation::findOrFail($id);
|
||||||
@@ -263,26 +275,28 @@ class ConversationController extends Controller
|
|||||||
->whereNull('left_at')
|
->whereNull('left_at')
|
||||||
->update(['left_at' => now()]);
|
->update(['left_at' => now()]);
|
||||||
|
|
||||||
$this->touchConversationCachesForUsers($participantUserIds);
|
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||||
|
$this->broadcastConversationUpdate($conv, 'conversation.participant_removed', $participantUserIds);
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/messages/{conversation_id}/rename ──────────────────────────
|
// ── 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);
|
$conv = $this->findAuthorized($request, $id);
|
||||||
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
|
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
|
||||||
$this->requireAdmin($request, $id);
|
$this->requireAdmin($request, $id);
|
||||||
|
|
||||||
$data = $request->validate(['title' => 'required|string|max:120']);
|
$data = $request->validated();
|
||||||
$conv->update(['title' => $data['title']]);
|
$conv->update(['title' => $data['title']]);
|
||||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||||
->whereNull('left_at')
|
->whereNull('left_at')
|
||||||
->pluck('user_id')
|
->pluck('user_id')
|
||||||
->all();
|
->all();
|
||||||
$this->touchConversationCachesForUsers($participantUserIds);
|
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||||
|
$this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds);
|
||||||
|
|
||||||
return response()->json(['title' => $conv->title]);
|
return response()->json(['title' => $conv->title]);
|
||||||
}
|
}
|
||||||
@@ -307,8 +321,10 @@ class ConversationController extends Controller
|
|||||||
if (! $conv) {
|
if (! $conv) {
|
||||||
$conv = DB::transaction(function () use ($user, $recipient) {
|
$conv = DB::transaction(function () use ($user, $recipient) {
|
||||||
$conv = Conversation::create([
|
$conv = Conversation::create([
|
||||||
|
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||||
'type' => 'direct',
|
'type' => 'direct',
|
||||||
'created_by' => $user->id,
|
'created_by' => $user->id,
|
||||||
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ConversationParticipant::insert([
|
ConversationParticipant::insert([
|
||||||
@@ -320,17 +336,12 @@ class ConversationController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert first / next message
|
$this->sendMessage->execute($conv, $user, [
|
||||||
$message = $conv->messages()->create([
|
|
||||||
'sender_id' => $user->id,
|
|
||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
|
'client_temp_id' => $data['client_temp_id'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$conv->update(['last_message_at' => $message->created_at]);
|
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
|
||||||
app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user);
|
|
||||||
$this->touchConversationCachesForUsers([$user->id, $recipient->id]);
|
|
||||||
|
|
||||||
return response()->json($conv->load('allParticipants.user:id,username'), 201);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createGroup(Request $request, User $user, array $data): JsonResponse
|
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 = DB::transaction(function () use ($user, $data, $participantIds) {
|
||||||
$conv = Conversation::create([
|
$conv = Conversation::create([
|
||||||
|
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||||
'type' => 'group',
|
'type' => 'group',
|
||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
'created_by' => $user->id,
|
'created_by' => $user->id,
|
||||||
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$rows = array_map(fn ($uid) => [
|
$rows = array_map(fn ($uid) => [
|
||||||
@@ -353,27 +366,21 @@ class ConversationController extends Controller
|
|||||||
|
|
||||||
ConversationParticipant::insert($rows);
|
ConversationParticipant::insert($rows);
|
||||||
|
|
||||||
$message = $conv->messages()->create([
|
return $conv;
|
||||||
'sender_id' => $user->id,
|
|
||||||
'body' => $data['body'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$conv->update(['last_message_at' => $message->created_at]);
|
|
||||||
|
|
||||||
return [$conv, $message];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
[$conversation, $message] = $conv;
|
$this->sendMessage->execute($conv, $user, [
|
||||||
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user);
|
'body' => $data['body'],
|
||||||
$this->touchConversationCachesForUsers($participantIds);
|
'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
|
private function findAuthorized(Request $request, int $id): Conversation
|
||||||
{
|
{
|
||||||
$conv = Conversation::findOrFail($id);
|
$conv = Conversation::findOrFail($id);
|
||||||
$this->assertParticipant($request, $id);
|
$this->authorize('view', $conv);
|
||||||
return $conv;
|
return $conv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,28 +406,13 @@ class ConversationController extends Controller
|
|||||||
|
|
||||||
private function requireAdmin(Request $request, int $id): void
|
private function requireAdmin(Request $request, int $id): void
|
||||||
{
|
{
|
||||||
abort_unless(
|
$conversation = Conversation::findOrFail($id);
|
||||||
ConversationParticipant::where('conversation_id', $id)
|
$this->authorize('manageParticipants', $conversation);
|
||||||
->where('user_id', $request->user()->id)
|
|
||||||
->where('role', 'admin')
|
|
||||||
->whereNull('left_at')
|
|
||||||
->exists(),
|
|
||||||
403,
|
|
||||||
'Only admins can perform this action.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function touchConversationCachesForUsers(array $userIds): void
|
private function touchConversationCachesForUsers(array $userIds): void
|
||||||
{
|
{
|
||||||
foreach (array_unique($userIds) as $userId) {
|
$this->conversationState->touchConversationCachesForUsers($userIds);
|
||||||
if (! $userId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$versionKey = $this->cacheVersionKey((int) $userId);
|
|
||||||
Cache::add($versionKey, 1, now()->addDay());
|
|
||||||
Cache::increment($versionKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cacheVersionKey(int $userId): string
|
private function cacheVersionKey(int $userId): string
|
||||||
@@ -433,6 +425,16 @@ class ConversationController extends Controller
|
|||||||
return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}";
|
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
|
private function assertNotBlockedBetween(User $sender, User $recipient): void
|
||||||
{
|
{
|
||||||
if (! Schema::hasTable('user_blocks')) {
|
if (! Schema::hasTable('user_blocks')) {
|
||||||
|
|||||||
@@ -2,31 +2,55 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Messaging;
|
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\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\Conversation;
|
||||||
use App\Models\ConversationParticipant;
|
use App\Models\ConversationParticipant;
|
||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use App\Models\MessageAttachment;
|
|
||||||
use App\Models\MessageReaction;
|
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\MessageSearchIndexer;
|
||||||
use App\Services\Messaging\MessageNotificationService;
|
use App\Services\Messaging\SendMessageAction;
|
||||||
|
use App\Services\Messaging\UnreadCounterService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class MessageController extends Controller
|
class MessageController extends Controller
|
||||||
{
|
{
|
||||||
private const PAGE_SIZE = 30;
|
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} ──────────────────────────────────
|
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
|
||||||
|
|
||||||
public function index(Request $request, int $conversationId): JsonResponse
|
public function index(Request $request, int $conversationId): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertParticipant($request, $conversationId);
|
$conversation = $this->findConversationOrFail($conversationId);
|
||||||
$cursor = $request->integer('cursor');
|
$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()
|
$query = Message::withTrashed()
|
||||||
->where('conversation_id', $conversationId)
|
->where('conversation_id', $conversationId)
|
||||||
@@ -44,65 +68,49 @@ class MessageController extends Controller
|
|||||||
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
|
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $messages,
|
'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(),
|
||||||
'next_cursor' => $nextCursor,
|
'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} ─────────────────────────────────
|
// ── 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);
|
$conversation = $this->findConversationOrFail($conversationId);
|
||||||
|
$data = $request->validated();
|
||||||
$data = $request->validate([
|
$data['attachments'] = $request->file('attachments', []);
|
||||||
'body' => 'nullable|string|max:5000',
|
|
||||||
'attachments' => 'sometimes|array|max:5',
|
|
||||||
'attachments.*' => 'file|max:25600',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$body = trim((string) ($data['body'] ?? ''));
|
$body = trim((string) ($data['body'] ?? ''));
|
||||||
$files = $request->file('attachments', []);
|
abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.');
|
||||||
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
|
|
||||||
|
|
||||||
$message = Message::create([
|
$message = $this->sendMessage->execute($conversation, $request->user(), $data);
|
||||||
'conversation_id' => $conversationId,
|
|
||||||
'sender_id' => $request->user()->id,
|
|
||||||
'body' => $body,
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201);
|
||||||
if ($file instanceof UploadedFile) {
|
|
||||||
$this->storeAttachment($file, $message, (int) $request->user()->id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Conversation::where('id', $conversationId)
|
|
||||||
->update(['last_message_at' => $message->created_at]);
|
|
||||||
|
|
||||||
$conversation = Conversation::findOrFail($conversationId);
|
|
||||||
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user());
|
|
||||||
app(MessageSearchIndexer::class)->indexMessage($message);
|
|
||||||
event(new MessageSent($conversationId, $message->id, $request->user()->id));
|
|
||||||
|
|
||||||
$participantUserIds = ConversationParticipant::where('conversation_id', $conversationId)
|
|
||||||
->whereNull('left_at')
|
|
||||||
->pluck('user_id')
|
|
||||||
->all();
|
|
||||||
$this->touchConversationCachesForUsers($participantUserIds);
|
|
||||||
|
|
||||||
$message->load(['sender:id,username', 'attachments']);
|
|
||||||
|
|
||||||
return response()->json($message, 201);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
|
// ── 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);
|
$this->findConversationOrFail($conversationId);
|
||||||
|
$data = $request->validated();
|
||||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
|
||||||
$this->assertAllowedReaction($data['reaction']);
|
$this->assertAllowedReaction($data['reaction']);
|
||||||
|
|
||||||
$existing = MessageReaction::where([
|
$existing = MessageReaction::where([
|
||||||
@@ -126,11 +134,10 @@ class MessageController extends Controller
|
|||||||
|
|
||||||
// ── DELETE /api/messages/{conversation_id}/react ─────────────────────────
|
// ── 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);
|
$this->findConversationOrFail($conversationId);
|
||||||
|
$data = $request->validated();
|
||||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
|
||||||
$this->assertAllowedReaction($data['reaction']);
|
$this->assertAllowedReaction($data['reaction']);
|
||||||
|
|
||||||
MessageReaction::where([
|
MessageReaction::where([
|
||||||
@@ -142,12 +149,11 @@ class MessageController extends Controller
|
|||||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
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);
|
$message = Message::query()->findOrFail($messageId);
|
||||||
$this->assertParticipant($request, (int) $message->conversation_id);
|
$this->findConversationOrFail((int) $message->conversation_id);
|
||||||
|
$data = $request->validated();
|
||||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
|
||||||
$this->assertAllowedReaction($data['reaction']);
|
$this->assertAllowedReaction($data['reaction']);
|
||||||
|
|
||||||
$existing = MessageReaction::where([
|
$existing = MessageReaction::where([
|
||||||
@@ -169,12 +175,11 @@ class MessageController extends Controller
|
|||||||
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
|
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);
|
$message = Message::query()->findOrFail($messageId);
|
||||||
$this->assertParticipant($request, (int) $message->conversation_id);
|
$this->findConversationOrFail((int) $message->conversation_id);
|
||||||
|
$data = $request->validated();
|
||||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
|
||||||
$this->assertAllowedReaction($data['reaction']);
|
$this->assertAllowedReaction($data['reaction']);
|
||||||
|
|
||||||
MessageReaction::where([
|
MessageReaction::where([
|
||||||
@@ -188,19 +193,15 @@ class MessageController extends Controller
|
|||||||
|
|
||||||
// ── PATCH /api/messages/message/{messageId} ───────────────────────────────
|
// ── 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);
|
$message = Message::findOrFail($messageId);
|
||||||
|
|
||||||
abort_unless(
|
$this->authorize('update', $message);
|
||||||
$message->sender_id === $request->user()->id,
|
|
||||||
403,
|
|
||||||
'You may only edit your own messages.'
|
|
||||||
);
|
|
||||||
|
|
||||||
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
|
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([
|
$message->update([
|
||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
@@ -208,13 +209,21 @@ class MessageController extends Controller
|
|||||||
]);
|
]);
|
||||||
app(MessageSearchIndexer::class)->updateMessage($message);
|
app(MessageSearchIndexer::class)->updateMessage($message);
|
||||||
|
|
||||||
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
|
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
|
||||||
->whereNull('left_at')
|
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||||
->pluck('user_id')
|
|
||||||
->all();
|
|
||||||
$this->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} ──────────────────────────────
|
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
|
||||||
@@ -223,19 +232,24 @@ class MessageController extends Controller
|
|||||||
{
|
{
|
||||||
$message = Message::findOrFail($messageId);
|
$message = Message::findOrFail($messageId);
|
||||||
|
|
||||||
abort_unless(
|
$this->authorize('delete', $message);
|
||||||
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
|
|
||||||
403,
|
|
||||||
'You may only delete your own messages.'
|
|
||||||
);
|
|
||||||
|
|
||||||
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
|
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
|
||||||
->whereNull('left_at')
|
|
||||||
->pluck('user_id')
|
|
||||||
->all();
|
|
||||||
app(MessageSearchIndexer::class)->deleteMessage($message);
|
app(MessageSearchIndexer::class)->deleteMessage($message);
|
||||||
$message->delete();
|
$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]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
@@ -256,15 +270,7 @@ class MessageController extends Controller
|
|||||||
|
|
||||||
private function touchConversationCachesForUsers(array $userIds): void
|
private function touchConversationCachesForUsers(array $userIds): void
|
||||||
{
|
{
|
||||||
foreach (array_unique($userIds) as $userId) {
|
$this->conversationState->touchConversationCachesForUsers($userIds);
|
||||||
if (! $userId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$versionKey = "messages:conversations:version:{$userId}";
|
|
||||||
Cache::add($versionKey, 1, now()->addDay());
|
|
||||||
Cache::increment($versionKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertAllowedReaction(string $reaction): void
|
private function assertAllowedReaction(string $reaction): void
|
||||||
@@ -298,54 +304,11 @@ class MessageController extends Controller
|
|||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void
|
private function findConversationOrFail(int $conversationId): Conversation
|
||||||
{
|
{
|
||||||
$mime = (string) $file->getMimeType();
|
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||||
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname());
|
$this->authorize('view', $conversation);
|
||||||
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
|
|
||||||
|
|
||||||
$allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []);
|
return $conversation;
|
||||||
$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(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,18 +71,12 @@ class MessageSearchController extends Controller
|
|||||||
|
|
||||||
$hits = collect($result->getHits() ?? []);
|
$hits = collect($result->getHits() ?? []);
|
||||||
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count());
|
$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();
|
if ($hits->isEmpty()) {
|
||||||
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
|
[$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();
|
$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
|
public function rebuild(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');
|
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ class MessagingSettingsController extends Controller
|
|||||||
{
|
{
|
||||||
public function show(Request $request): JsonResponse
|
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([
|
return response()->json([
|
||||||
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
|
'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\TypingStarted;
|
||||||
use App\Events\TypingStopped;
|
use App\Events\TypingStopped;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Conversation;
|
||||||
use App\Models\ConversationParticipant;
|
use App\Models\ConversationParticipant;
|
||||||
use Illuminate\Cache\Repository;
|
use Illuminate\Cache\Repository;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -15,13 +16,13 @@ class TypingController extends Controller
|
|||||||
{
|
{
|
||||||
public function start(Request $request, int $conversationId): JsonResponse
|
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));
|
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
|
||||||
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
|
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
|
||||||
|
|
||||||
if ((bool) config('messaging.realtime', false)) {
|
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]);
|
return response()->json(['ok' => true]);
|
||||||
@@ -29,11 +30,11 @@ class TypingController extends Controller
|
|||||||
|
|
||||||
public function stop(Request $request, int $conversationId): JsonResponse
|
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));
|
$this->store()->forget($this->key($conversationId, (int) $request->user()->id));
|
||||||
|
|
||||||
if ((bool) config('messaging.realtime', false)) {
|
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]);
|
return response()->json(['ok' => true]);
|
||||||
@@ -41,7 +42,7 @@ class TypingController extends Controller
|
|||||||
|
|
||||||
public function index(Request $request, int $conversationId): JsonResponse
|
public function index(Request $request, int $conversationId): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertParticipant($request, $conversationId);
|
$this->findConversationOrFail($conversationId);
|
||||||
$userId = (int) $request->user()->id;
|
$userId = (int) $request->user()->id;
|
||||||
|
|
||||||
$participants = ConversationParticipant::query()
|
$participants = ConversationParticipant::query()
|
||||||
@@ -93,4 +94,12 @@ class TypingController extends Controller
|
|||||||
return Cache::store();
|
return Cache::store();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function findConversationOrFail(int $conversationId): Conversation
|
||||||
|
{
|
||||||
|
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||||
|
$this->authorize('view', $conversation);
|
||||||
|
|
||||||
|
return $conversation;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Services\Posts\NotificationDigestService;
|
use App\Services\NotificationService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
@@ -14,48 +14,24 @@ use App\Http\Controllers\Controller;
|
|||||||
*/
|
*/
|
||||||
class NotificationController extends Controller
|
class NotificationController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private NotificationDigestService $digest) {}
|
public function __construct(private NotificationService $notifications) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
return response()->json(
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$this->notifications->listForUser($request->user(), (int) $request->query('page', 1), 20)
|
||||||
|
);
|
||||||
$notifications = $user->notifications()
|
|
||||||
->latest()
|
|
||||||
->limit(200) // aggregate from last 200 raw notifs
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$digested = $this->digest->aggregate($notifications);
|
|
||||||
|
|
||||||
// Simple manual pagination on the digested array
|
|
||||||
$perPage = 20;
|
|
||||||
$total = count($digested);
|
|
||||||
$sliced = array_slice($digested, ($page - 1) * $perPage, $perPage);
|
|
||||||
$unread = $user->unreadNotifications()->count();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => array_values($sliced),
|
|
||||||
'unread_count' => $unread,
|
|
||||||
'meta' => [
|
|
||||||
'total' => $total,
|
|
||||||
'current_page' => $page,
|
|
||||||
'last_page' => (int) ceil($total / $perPage) ?: 1,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function readAll(Request $request): JsonResponse
|
public function readAll(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->user()->unreadNotifications->markAsRead();
|
$this->notifications->markAllRead($request->user());
|
||||||
return response()->json(['message' => 'All notifications marked as read.']);
|
return response()->json(['message' => 'All notifications marked as read.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function markRead(Request $request, string $id): JsonResponse
|
public function markRead(Request $request, string $id): JsonResponse
|
||||||
{
|
{
|
||||||
$notif = $request->user()->notifications()->findOrFail($id);
|
$this->notifications->markRead($request->user(), $id);
|
||||||
$notif->markAsRead();
|
|
||||||
return response()->json(['message' => 'Notification marked as read.']);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\NovaCards;
|
||||||
|
|
||||||
|
use App\Jobs\UpdateNovaCardStatsJob;
|
||||||
|
use App\Events\NovaCards\NovaCardDownloaded;
|
||||||
|
use App\Events\NovaCards\NovaCardShared;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\NovaCard;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class NovaCardEngagementController extends Controller
|
||||||
|
{
|
||||||
|
public function share(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->card($request, $id);
|
||||||
|
$card->increment('shares_count');
|
||||||
|
$card->refresh();
|
||||||
|
UpdateNovaCardStatsJob::dispatch($card->id);
|
||||||
|
|
||||||
|
event(new NovaCardShared($card, $request->user()?->id));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'shares_count' => (int) $card->shares_count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function download(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->card($request, $id);
|
||||||
|
abort_unless($card->allow_download && $card->previewUrl() !== null, 404);
|
||||||
|
|
||||||
|
$card->increment('downloads_count');
|
||||||
|
$card->refresh();
|
||||||
|
UpdateNovaCardStatsJob::dispatch($card->id);
|
||||||
|
|
||||||
|
event(new NovaCardDownloaded($card, $request->user()?->id));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'downloads_count' => (int) $card->downloads_count,
|
||||||
|
'download_url' => $card->previewUrl(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function card(Request $request, int $id): NovaCard
|
||||||
|
{
|
||||||
|
$card = NovaCard::query()
|
||||||
|
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
|
||||||
|
->published()
|
||||||
|
->findOrFail($id);
|
||||||
|
|
||||||
|
abort_unless($card->canBeViewedBy($request->user()), 404);
|
||||||
|
|
||||||
|
return $card;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\NovaCards;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\NovaCard;
|
||||||
|
use App\Models\NovaCardExport;
|
||||||
|
use App\Services\NovaCards\NovaCardExportService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class NovaCardExportController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NovaCardExportService $exports,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request an export for the given card.
|
||||||
|
*
|
||||||
|
* POST /api/cards/{id}/export
|
||||||
|
*/
|
||||||
|
public function store(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = NovaCard::query()
|
||||||
|
->where(function ($q) use ($request): void {
|
||||||
|
// Owner can export any status; others can only export published cards.
|
||||||
|
$q->where('user_id', $request->user()->id)
|
||||||
|
->orWhere(function ($inner) use ($request): void {
|
||||||
|
$inner->where('status', NovaCard::STATUS_PUBLISHED)
|
||||||
|
->where('visibility', NovaCard::VISIBILITY_PUBLIC)
|
||||||
|
->where('allow_export', true);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->findOrFail($id);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'export_type' => ['required', 'string', 'in:' . implode(',', array_keys(NovaCardExportService::EXPORT_SPECS))],
|
||||||
|
'options' => ['sometimes', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$export = $this->exports->requestExport(
|
||||||
|
$request->user(),
|
||||||
|
$card,
|
||||||
|
$data['export_type'],
|
||||||
|
(array) ($data['options'] ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->exports->getStatus($export),
|
||||||
|
], $export->wasRecentlyCreated ? Response::HTTP_ACCEPTED : Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll export status.
|
||||||
|
*
|
||||||
|
* GET /api/cards/exports/{exportId}
|
||||||
|
*/
|
||||||
|
public function show(Request $request, int $exportId): JsonResponse
|
||||||
|
{
|
||||||
|
$export = NovaCardExport::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->findOrFail($exportId);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->exports->getStatus($export),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\NovaCards;
|
||||||
|
|
||||||
|
use App\Jobs\UpdateNovaCardStatsJob;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\NovaCard;
|
||||||
|
use App\Models\NovaCardChallenge;
|
||||||
|
use App\Models\NovaCardReaction;
|
||||||
|
use App\Models\NovaCardVersion;
|
||||||
|
use App\Services\NovaCards\NovaCardChallengeService;
|
||||||
|
use App\Services\NovaCards\NovaCardCollectionService;
|
||||||
|
use App\Services\NovaCards\NovaCardDraftService;
|
||||||
|
use App\Services\NovaCards\NovaCardLineageService;
|
||||||
|
use App\Services\NovaCards\NovaCardPresenter;
|
||||||
|
use App\Services\NovaCards\NovaCardReactionService;
|
||||||
|
use App\Services\NovaCards\NovaCardVersionService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class NovaCardInteractionController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NovaCardReactionService $reactions,
|
||||||
|
private readonly NovaCardCollectionService $collections,
|
||||||
|
private readonly NovaCardDraftService $drafts,
|
||||||
|
private readonly NovaCardVersionService $versions,
|
||||||
|
private readonly NovaCardChallengeService $challenges,
|
||||||
|
private readonly NovaCardLineageService $lineage,
|
||||||
|
private readonly NovaCardPresenter $presenter,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lineage(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->visibleCard($request, $id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->lineage->resolve($card, $request->user()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function like(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->visibleCard($request, $id);
|
||||||
|
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_LIKE, true);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, ...$state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unlike(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->visibleCard($request, $id);
|
||||||
|
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_LIKE, false);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, ...$state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function favorite(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->visibleCard($request, $id);
|
||||||
|
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_FAVORITE, true);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, ...$state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unfavorite(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->visibleCard($request, $id);
|
||||||
|
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_FAVORITE, false);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, ...$state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collections(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->collections->listCollections($request->user()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeCollection(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'min:2', 'max:120'],
|
||||||
|
'slug' => ['nullable', 'string', 'max:140'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'visibility' => ['nullable', 'in:private,public'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$collection = $this->collections->createCollection($request->user(), $payload);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'collection' => [
|
||||||
|
'id' => (int) $collection->id,
|
||||||
|
'slug' => (string) $collection->slug,
|
||||||
|
'name' => (string) $collection->name,
|
||||||
|
'description' => $collection->description,
|
||||||
|
'visibility' => (string) $collection->visibility,
|
||||||
|
'cards_count' => (int) $collection->cards_count,
|
||||||
|
],
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateCollection(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$collection = $this->ownedCollection($request, $id);
|
||||||
|
$payload = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'min:2', 'max:120'],
|
||||||
|
'slug' => ['nullable', 'string', 'max:140'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'visibility' => ['nullable', 'in:private,public'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$collection->update([
|
||||||
|
'name' => $payload['name'],
|
||||||
|
'slug' => $payload['slug'] ?: $collection->slug,
|
||||||
|
'description' => $payload['description'] ?? null,
|
||||||
|
'visibility' => $payload['visibility'] ?? $collection->visibility,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeCollectionItem(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$collection = $this->ownedCollection($request, $id);
|
||||||
|
$payload = $request->validate([
|
||||||
|
'card_id' => ['required', 'integer'],
|
||||||
|
'note' => ['nullable', 'string', 'max:500'],
|
||||||
|
'sort_order' => ['nullable', 'integer', 'min:0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$card = $this->visibleCard($request, (int) $payload['card_id']);
|
||||||
|
$this->collections->addCardToCollection($collection, $card, $payload['note'] ?? null, $payload['sort_order'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroyCollectionItem(Request $request, int $id, int $cardId): JsonResponse
|
||||||
|
{
|
||||||
|
$collection = $this->ownedCollection($request, $id);
|
||||||
|
$card = NovaCard::query()->findOrFail($cardId);
|
||||||
|
$this->collections->removeCardFromCollection($collection, $card);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function challenges(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->presenter->options()['challenge_feed'] ?? [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assets(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->presenter->options()['asset_packs'] ?? [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function templates(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'packs' => $this->presenter->options()['template_packs'] ?? [],
|
||||||
|
'templates' => $this->presenter->options()['templates'] ?? [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->visibleCard($request, $id);
|
||||||
|
$payload = $request->validate([
|
||||||
|
'collection_id' => ['nullable', 'integer'],
|
||||||
|
'note' => ['nullable', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
$collection = $this->collections->saveCard($request->user(), $card, $payload['collection_id'] ?? null, $payload['note'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'saves_count' => (int) $card->fresh()->saves_count,
|
||||||
|
'collection' => [
|
||||||
|
'id' => (int) $collection->id,
|
||||||
|
'name' => (string) $collection->name,
|
||||||
|
'slug' => (string) $collection->slug,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unsave(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->visibleCard($request, $id);
|
||||||
|
$payload = $request->validate([
|
||||||
|
'collection_id' => ['nullable', 'integer'],
|
||||||
|
]);
|
||||||
|
$this->collections->unsaveCard($request->user(), $card, $payload['collection_id'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'saves_count' => (int) $card->fresh()->saves_count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remix(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$source = $this->visibleCard($request, $id);
|
||||||
|
abort_unless($source->allow_remix, 422, 'This card does not allow remixes.');
|
||||||
|
|
||||||
|
$card = $this->drafts->createRemix($request->user(), $source);
|
||||||
|
$source->increment('remixes_count');
|
||||||
|
UpdateNovaCardStatsJob::dispatch($source->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->presenter->card($card, true, $request->user()),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function duplicate(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$source = $this->ownedCard($request, $id);
|
||||||
|
$card = $this->drafts->createDuplicate($request->user(), $source->loadMissing('tags'));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->presenter->card($card, true, $request->user()),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function versions(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->ownedCard($request, $id);
|
||||||
|
$versions = $card->versions()
|
||||||
|
->latest('version_number')
|
||||||
|
->get()
|
||||||
|
->map(fn (NovaCardVersion $version): array => [
|
||||||
|
'id' => (int) $version->id,
|
||||||
|
'version_number' => (int) $version->version_number,
|
||||||
|
'label' => $version->label,
|
||||||
|
'created_at' => $version->created_at?->toISOString(),
|
||||||
|
'snapshot_json' => is_array($version->snapshot_json) ? $version->snapshot_json : [],
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return response()->json(['data' => $versions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->ownedCard($request, $id);
|
||||||
|
$version = $card->versions()->findOrFail($versionId);
|
||||||
|
$card = $this->versions->restore($card, $version, $request->user());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->presenter->card($card, true, $request->user()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitChallenge(Request $request, int $challengeId, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'note' => ['nullable', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->submitChallengeWithPayload($request, $challengeId, $id, $payload['note'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitChallengeByChallenge(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'card_id' => ['required', 'integer'],
|
||||||
|
'note' => ['nullable', 'string', 'max:500'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->submitChallengeWithPayload($request, $id, (int) $payload['card_id'], $payload['note'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function submitChallengeWithPayload(Request $request, int $challengeId, int $cardId, ?string $note = null): JsonResponse
|
||||||
|
{
|
||||||
|
$card = $this->ownedCard($request, $cardId);
|
||||||
|
abort_unless($card->status === NovaCard::STATUS_PUBLISHED, 422, 'Publish the card before entering a challenge.');
|
||||||
|
|
||||||
|
$challenge = NovaCardChallenge::query()
|
||||||
|
->where('status', NovaCardChallenge::STATUS_ACTIVE)
|
||||||
|
->findOrFail($challengeId);
|
||||||
|
|
||||||
|
$entry = $this->challenges->submit($request->user(), $challenge, $card, $note);
|
||||||
|
UpdateNovaCardStatsJob::dispatch($card->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'entry' => [
|
||||||
|
'id' => (int) $entry->id,
|
||||||
|
'challenge_id' => (int) $entry->challenge_id,
|
||||||
|
'card_id' => (int) $entry->card_id,
|
||||||
|
'status' => (string) $entry->status,
|
||||||
|
],
|
||||||
|
'challenge_entries_count' => (int) $card->fresh()->challenge_entries_count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function visibleCard(Request $request, int $id): NovaCard
|
||||||
|
{
|
||||||
|
$card = NovaCard::query()
|
||||||
|
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard'])
|
||||||
|
->published()
|
||||||
|
->findOrFail($id);
|
||||||
|
|
||||||
|
abort_unless($card->canBeViewedBy($request->user()), 404);
|
||||||
|
|
||||||
|
return $card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownedCard(Request $request, int $id): NovaCard
|
||||||
|
{
|
||||||
|
return NovaCard::query()
|
||||||
|
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'versions'])
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->findOrFail($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownedCollection(Request $request, int $id): \App\Models\NovaCardCollection
|
||||||
|
{
|
||||||
|
return \App\Models\NovaCardCollection::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->findOrFail($id);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/Http/Controllers/Api/NovaCards/NovaCardPresetController.php
Normal file
131
app/Http/Controllers/Api/NovaCards/NovaCardPresetController.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\NovaCards;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\NovaCard;
|
||||||
|
use App\Models\NovaCardCreatorPreset;
|
||||||
|
use App\Services\NovaCards\NovaCardCreatorPresetService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class NovaCardPresetController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NovaCardCreatorPresetService $presets,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$type = $request->query('type');
|
||||||
|
$items = $this->presets->listForUser(
|
||||||
|
$request->user(),
|
||||||
|
is_string($type) && in_array($type, NovaCardCreatorPreset::TYPES, true) ? $type : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $items->map(fn ($p) => $this->presets->toArray($p))->values()->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'preset_type' => ['required', 'string', 'in:' . implode(',', NovaCardCreatorPreset::TYPES)],
|
||||||
|
'config_json' => ['required', 'array'],
|
||||||
|
'is_default' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$preset = $this->presets->create($request->user(), $data);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->presets->toArray($preset),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$preset = NovaCardCreatorPreset::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->findOrFail($id);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['sometimes', 'string', 'max:120'],
|
||||||
|
'config_json' => ['sometimes', 'array'],
|
||||||
|
'is_default' => ['sometimes', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$preset = $this->presets->update($request->user(), $preset, $data);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->presets->toArray($preset),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$preset = NovaCardCreatorPreset::query()->findOrFail($id);
|
||||||
|
|
||||||
|
$this->presets->delete($request->user(), $preset);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a preset from an existing published card.
|
||||||
|
*/
|
||||||
|
public function captureFromCard(Request $request, int $cardId): JsonResponse
|
||||||
|
{
|
||||||
|
$card = NovaCard::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->findOrFail($cardId);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'preset_type' => ['required', 'string', 'in:' . implode(',', NovaCardCreatorPreset::TYPES)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$preset = $this->presets->captureFromCard(
|
||||||
|
$request->user(),
|
||||||
|
$card,
|
||||||
|
$data['name'],
|
||||||
|
$data['preset_type'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->presets->toArray($preset),
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a saved preset to a draft card, returning a project_json patch.
|
||||||
|
*/
|
||||||
|
public function applyToCard(Request $request, int $presetId, int $cardId): JsonResponse
|
||||||
|
{
|
||||||
|
$preset = NovaCardCreatorPreset::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->findOrFail($presetId);
|
||||||
|
|
||||||
|
$card = NovaCard::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->whereIn('status', [NovaCard::STATUS_DRAFT, NovaCard::STATUS_PUBLISHED])
|
||||||
|
->findOrFail($cardId);
|
||||||
|
|
||||||
|
$currentProject = is_array($card->project_json) ? $card->project_json : [];
|
||||||
|
$patch = $this->presets->applyToProjectPatch($preset, $currentProject);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'preset' => $this->presets->toArray($preset),
|
||||||
|
'project_patch' => $patch,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,8 @@ class PostCommentController extends Controller
|
|||||||
'username' => $comment->user->username,
|
'username' => $comment->user->username,
|
||||||
'name' => $comment->user->name,
|
'name' => $comment->user->name,
|
||||||
'avatar' => $comment->user->profile?->avatar_url ?? null,
|
'avatar' => $comment->user->profile?->avatar_url ?? null,
|
||||||
|
'level' => (int) ($comment->user->level ?? 1),
|
||||||
|
'rank' => (string) ($comment->user->rank ?? 'Newbie'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
38
app/Http/Controllers/Api/ProfileActivityController.php
Normal file
38
app/Http/Controllers/Api/ProfileActivityController.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Activity\UserActivityService;
|
||||||
|
use App\Support\UsernamePolicy;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
final class ProfileActivityController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly UserActivityService $activities) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request, string $username): JsonResponse
|
||||||
|
{
|
||||||
|
$normalized = UsernamePolicy::normalize($username);
|
||||||
|
|
||||||
|
$user = User::query()
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->whereRaw('LOWER(username) = ?', [$normalized])
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
$this->activities->feedForUser(
|
||||||
|
$user,
|
||||||
|
(string) $request->query('filter', 'all'),
|
||||||
|
(int) $request->query('page', 1),
|
||||||
|
(int) $request->query('per_page', UserActivityService::DEFAULT_PER_PAGE),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,8 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use App\Services\ThumbnailPresenter;
|
use App\Services\ThumbnailPresenter;
|
||||||
use App\Services\ThumbnailService;
|
|
||||||
use App\Support\AvatarUrl;
|
|
||||||
use App\Support\UsernamePolicy;
|
use App\Support\UsernamePolicy;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -38,7 +37,14 @@ final class ProfileApiController extends Controller
|
|||||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||||
$sort = $request->input('sort', 'latest');
|
$sort = $request->input('sort', 'latest');
|
||||||
|
|
||||||
$query = Artwork::with('user:id,name,username')
|
$query = Artwork::with([
|
||||||
|
'user:id,name,username,level,rank',
|
||||||
|
'stats:artwork_id,views,downloads,favorites',
|
||||||
|
'categories' => function ($query) {
|
||||||
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
|
->with(['contentType:id,slug,name']);
|
||||||
|
},
|
||||||
|
])
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->whereNull('deleted_at');
|
->whereNull('deleted_at');
|
||||||
|
|
||||||
@@ -57,20 +63,9 @@ final class ProfileApiController extends Controller
|
|||||||
$perPage = 24;
|
$perPage = 24;
|
||||||
$paginator = $query->cursorPaginate($perPage);
|
$paginator = $query->cursorPaginate($perPage);
|
||||||
|
|
||||||
$data = collect($paginator->items())->map(function (Artwork $art) {
|
$data = collect($paginator->items())
|
||||||
$present = ThumbnailPresenter::present($art, 'md');
|
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
|
||||||
return [
|
->values();
|
||||||
'id' => $art->id,
|
|
||||||
'name' => $art->title,
|
|
||||||
'thumb' => $present['url'],
|
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
|
||||||
'width' => $art->width,
|
|
||||||
'height' => $art->height,
|
|
||||||
'username' => $art->user->username ?? null,
|
|
||||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
|
||||||
'published_at' => $art->published_at,
|
|
||||||
];
|
|
||||||
})->values();
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
@@ -85,7 +80,8 @@ final class ProfileApiController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function favourites(Request $request, string $username): JsonResponse
|
public function favourites(Request $request, string $username): JsonResponse
|
||||||
{
|
{
|
||||||
if (! Schema::hasTable('user_favorites')) {
|
$favouriteTable = $this->resolveFavouriteTable();
|
||||||
|
if ($favouriteTable === null) {
|
||||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,16 +91,18 @@ final class ProfileApiController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$perPage = 24;
|
$perPage = 24;
|
||||||
$cursor = $request->input('cursor');
|
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
||||||
|
|
||||||
$favIds = DB::table('user_favorites as uf')
|
$favIds = DB::table($favouriteTable . ' as af')
|
||||||
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||||
->where('uf.user_id', $user->id)
|
->where('af.user_id', $user->id)
|
||||||
->whereNull('a.deleted_at')
|
->whereNull('a.deleted_at')
|
||||||
->where('a.is_public', true)
|
->where('a.is_public', true)
|
||||||
->where('a.is_approved', true)
|
->where('a.is_approved', true)
|
||||||
->orderByDesc('uf.created_at')
|
->whereNotNull('a.published_at')
|
||||||
->offset($cursor ? (int) base64_decode($cursor) : 0)
|
->orderByDesc('af.created_at')
|
||||||
|
->orderByDesc('af.artwork_id')
|
||||||
|
->offset($offset)
|
||||||
->limit($perPage + 1)
|
->limit($perPage + 1)
|
||||||
->pluck('a.id');
|
->pluck('a.id');
|
||||||
|
|
||||||
@@ -115,29 +113,26 @@ final class ProfileApiController extends Controller
|
|||||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$indexed = Artwork::with('user:id,name,username')
|
$indexed = Artwork::with([
|
||||||
|
'user:id,name,username,level,rank',
|
||||||
|
'stats:artwork_id,views,downloads,favorites',
|
||||||
|
'categories' => function ($query) {
|
||||||
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
|
->with(['contentType:id,slug,name']);
|
||||||
|
},
|
||||||
|
])
|
||||||
->whereIn('id', $favIds)
|
->whereIn('id', $favIds)
|
||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
$data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) {
|
$data = $favIds
|
||||||
$art = $indexed[$id];
|
->filter(fn ($id) => $indexed->has($id))
|
||||||
$present = ThumbnailPresenter::present($art, 'md');
|
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
||||||
return [
|
->values();
|
||||||
'id' => $art->id,
|
|
||||||
'name' => $art->title,
|
|
||||||
'thumb' => $present['url'],
|
|
||||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
|
||||||
'width' => $art->width,
|
|
||||||
'height' => $art->height,
|
|
||||||
'username' => $art->user->username ?? null,
|
|
||||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
|
||||||
];
|
|
||||||
})->values();
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
'next_cursor' => null, // Simple offset pagination for now
|
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
|
||||||
'has_more' => $hasMore,
|
'has_more' => $hasMore,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -174,4 +169,58 @@ final class ProfileApiController extends Controller
|
|||||||
$normalized = UsernamePolicy::normalize($username);
|
$normalized = UsernamePolicy::normalize($username);
|
||||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveFavouriteTable(): ?string
|
||||||
|
{
|
||||||
|
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
||||||
|
if (Schema::hasTable($table)) {
|
||||||
|
return $table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapArtworkCardPayload(Artwork $art): array
|
||||||
|
{
|
||||||
|
$present = ThumbnailPresenter::present($art, 'md');
|
||||||
|
$category = $art->categories->first();
|
||||||
|
$contentType = $category?->contentType;
|
||||||
|
$stats = $art->stats;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $art->id,
|
||||||
|
'name' => $art->title,
|
||||||
|
'thumb' => $present['url'],
|
||||||
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||||
|
'width' => $art->width,
|
||||||
|
'height' => $art->height,
|
||||||
|
'username' => $art->user->username ?? null,
|
||||||
|
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||||
|
'content_type' => $contentType?->name,
|
||||||
|
'content_type_slug' => $contentType?->slug,
|
||||||
|
'category' => $category?->name,
|
||||||
|
'category_slug' => $category?->slug,
|
||||||
|
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
|
||||||
|
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||||
|
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
|
||||||
|
'published_at' => $this->formatIsoDate($art->published_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatIsoDate(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value instanceof CarbonInterface) {
|
||||||
|
return $value->toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof \DateTimeInterface) {
|
||||||
|
return $value->format(DATE_ATOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($value) ? $value : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,22 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ConversationParticipant;
|
|
||||||
use App\Models\Message;
|
|
||||||
use App\Models\Report;
|
use App\Models\Report;
|
||||||
use App\Models\User;
|
use App\Support\Moderation\ReportTargetResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class ReportController extends Controller
|
class ReportController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ReportTargetResolver $targets) {}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'target_type' => 'required|in:message,conversation,user',
|
'target_type' => ['required', Rule::in($this->targets->supportedTargetTypes())],
|
||||||
'target_id' => 'required|integer|min:1',
|
'target_id' => 'required|integer|min:1',
|
||||||
'reason' => 'required|string|max:120',
|
'reason' => 'required|string|max:120',
|
||||||
'details' => 'nullable|string|max:4000',
|
'details' => 'nullable|string|max:4000',
|
||||||
@@ -26,28 +27,7 @@ class ReportController extends Controller
|
|||||||
$targetType = $data['target_type'];
|
$targetType = $data['target_type'];
|
||||||
$targetId = (int) $data['target_id'];
|
$targetId = (int) $data['target_id'];
|
||||||
|
|
||||||
if ($targetType === 'message') {
|
$this->targets->validateForReporter($user, $targetType, $targetId);
|
||||||
$message = Message::query()->findOrFail($targetId);
|
|
||||||
$allowed = ConversationParticipant::query()
|
|
||||||
->where('conversation_id', $message->conversation_id)
|
|
||||||
->where('user_id', $user->id)
|
|
||||||
->whereNull('left_at')
|
|
||||||
->exists();
|
|
||||||
abort_unless($allowed, 403, 'You are not allowed to report this message.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($targetType === 'conversation') {
|
|
||||||
$allowed = ConversationParticipant::query()
|
|
||||||
->where('conversation_id', $targetId)
|
|
||||||
->where('user_id', $user->id)
|
|
||||||
->whereNull('left_at')
|
|
||||||
->exists();
|
|
||||||
abort_unless($allowed, 403, 'You are not allowed to report this conversation.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($targetType === 'user') {
|
|
||||||
User::query()->findOrFail($targetId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$report = Report::query()->create([
|
$report = Report::query()->create([
|
||||||
'reporter_id' => $user->id,
|
'reporter_id' => $user->id,
|
||||||
|
|||||||
55
app/Http/Controllers/Api/SimilarAiArtworksController.php
Normal file
55
app/Http/Controllers/Api/SimilarAiArtworksController.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Services\Vision\VectorService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class SimilarAiArtworksController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly VectorService $vectors)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::query()->public()->published()->find($id);
|
||||||
|
if ($artwork === null) {
|
||||||
|
return response()->json(['error' => 'Artwork not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->vectors->isConfigured()) {
|
||||||
|
return response()->json([
|
||||||
|
'data' => [],
|
||||||
|
'reason' => 'vector_gateway_not_configured',
|
||||||
|
], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = max(1, min(24, (int) $request->query('limit', 12)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$items = $this->vectors->similarToArtwork($artwork, $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',
|
||||||
|
'artwork_id' => $artwork->id,
|
||||||
|
'limit' => $limit,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Http/Controllers/Api/SocialActivityController.php
Normal file
34
app/Http/Controllers/Api/SocialActivityController.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\ActivityService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class SocialActivityController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ActivityService $activity) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$filter = (string) $request->query('filter', 'all');
|
||||||
|
|
||||||
|
if ($this->activity->requiresAuthentication($filter) && ! $request->user()) {
|
||||||
|
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
$this->activity->communityFeed(
|
||||||
|
viewer: $request->user(),
|
||||||
|
filter: $filter,
|
||||||
|
page: (int) $request->query('page', 1),
|
||||||
|
perPage: (int) $request->query('per_page', 20),
|
||||||
|
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
212
app/Http/Controllers/Api/SocialCompatibilityController.php
Normal file
212
app/Http/Controllers/Api/SocialCompatibilityController.php
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Story;
|
||||||
|
use App\Models\StoryBookmark;
|
||||||
|
use App\Services\SocialService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class SocialCompatibilityController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SocialService $social) {}
|
||||||
|
|
||||||
|
public function like(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||||
|
'entity_id' => ['required', 'integer'],
|
||||||
|
'state' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$state = array_key_exists('state', $payload)
|
||||||
|
? (bool) $payload['state']
|
||||||
|
: ! $request->isMethod('delete');
|
||||||
|
|
||||||
|
if ($payload['entity_type'] === 'story') {
|
||||||
|
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||||
|
|
||||||
|
$result = $this->social->toggleStoryLike($request->user(), $story, $state);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($result['ok'] ?? true),
|
||||||
|
'liked' => (bool) ($result['liked'] ?? false),
|
||||||
|
'likes_count' => (int) ($result['likes_count'] ?? 0),
|
||||||
|
'is_liked' => (bool) ($result['liked'] ?? false),
|
||||||
|
'stats' => [
|
||||||
|
'likes' => (int) ($result['likes_count'] ?? 0),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworkId = (int) $payload['entity_id'];
|
||||||
|
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||||
|
|
||||||
|
return app(ArtworkInteractionController::class)->like(
|
||||||
|
$request->merge(['state' => $state]),
|
||||||
|
$artworkId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function comments(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||||
|
'entity_id' => ['required', 'integer'],
|
||||||
|
'content' => [$request->isMethod('get') ? 'nullable' : 'required', 'string', 'min:1', 'max:10000'],
|
||||||
|
'parent_id' => ['nullable', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($payload['entity_type'] === 'story') {
|
||||||
|
if ($request->isMethod('get')) {
|
||||||
|
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||||
|
$comment = $this->social->addStoryComment(
|
||||||
|
$request->user(),
|
||||||
|
$story,
|
||||||
|
(string) $payload['content'],
|
||||||
|
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->social->formatComment($comment, (int) $request->user()->id, true),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworkId = (int) $payload['entity_id'];
|
||||||
|
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||||
|
|
||||||
|
if ($request->isMethod('get')) {
|
||||||
|
return app(ArtworkCommentController::class)->index($request, $artworkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(ArtworkCommentController::class)->store(
|
||||||
|
$request->merge([
|
||||||
|
'content' => $payload['content'],
|
||||||
|
'parent_id' => $payload['parent_id'] ?? null,
|
||||||
|
]),
|
||||||
|
$artworkId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bookmark(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||||
|
'entity_id' => ['required', 'integer'],
|
||||||
|
'state' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$state = array_key_exists('state', $payload)
|
||||||
|
? (bool) $payload['state']
|
||||||
|
: ! $request->isMethod('delete');
|
||||||
|
|
||||||
|
if ($payload['entity_type'] === 'story') {
|
||||||
|
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||||
|
|
||||||
|
$result = $this->social->toggleStoryBookmark($request->user(), $story, $state);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($result['ok'] ?? true),
|
||||||
|
'bookmarked' => (bool) ($result['bookmarked'] ?? false),
|
||||||
|
'bookmarks_count' => (int) ($result['bookmarks_count'] ?? 0),
|
||||||
|
'is_bookmarked' => (bool) ($result['bookmarked'] ?? false),
|
||||||
|
'stats' => [
|
||||||
|
'bookmarks' => (int) ($result['bookmarks_count'] ?? 0),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworkId = (int) $payload['entity_id'];
|
||||||
|
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||||
|
|
||||||
|
return app(ArtworkInteractionController::class)->bookmark(
|
||||||
|
$request->merge(['state' => $state]),
|
||||||
|
$artworkId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bookmarks(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'entity_type' => ['nullable', 'string', 'in:artwork,story'],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$perPage = (int) ($payload['per_page'] ?? 20);
|
||||||
|
$userId = (int) $request->user()->id;
|
||||||
|
$type = $payload['entity_type'] ?? null;
|
||||||
|
|
||||||
|
$items = collect();
|
||||||
|
|
||||||
|
if ($type === null || $type === 'artwork') {
|
||||||
|
$items = $items->concat(
|
||||||
|
Schema::hasTable('artwork_bookmarks')
|
||||||
|
? DB::table('artwork_bookmarks')
|
||||||
|
->join('artworks', 'artworks.id', '=', 'artwork_bookmarks.artwork_id')
|
||||||
|
->where('artwork_bookmarks.user_id', $userId)
|
||||||
|
->where('artworks.is_public', true)
|
||||||
|
->where('artworks.is_approved', true)
|
||||||
|
->select([
|
||||||
|
'artwork_bookmarks.created_at as saved_at',
|
||||||
|
'artworks.id',
|
||||||
|
'artworks.title',
|
||||||
|
'artworks.slug',
|
||||||
|
])
|
||||||
|
->latest('artwork_bookmarks.created_at')
|
||||||
|
->limit($perPage)
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => [
|
||||||
|
'type' => 'artwork',
|
||||||
|
'id' => (int) $row->id,
|
||||||
|
'title' => (string) $row->title,
|
||||||
|
'url' => route('art.show', ['id' => (int) $row->id, 'slug' => Str::slug((string) ($row->slug ?: $row->title)) ?: (string) $row->id]),
|
||||||
|
'saved_at' => Carbon::parse($row->saved_at)->toIso8601String(),
|
||||||
|
])
|
||||||
|
: collect()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === null || $type === 'story') {
|
||||||
|
$items = $items->concat(
|
||||||
|
StoryBookmark::query()
|
||||||
|
->with('story:id,slug,title')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->latest('created_at')
|
||||||
|
->limit($perPage)
|
||||||
|
->get()
|
||||||
|
->filter(fn (StoryBookmark $bookmark) => $bookmark->story !== null)
|
||||||
|
->map(fn (StoryBookmark $bookmark) => [
|
||||||
|
'type' => 'story',
|
||||||
|
'id' => (int) $bookmark->story->id,
|
||||||
|
'title' => (string) $bookmark->story->title,
|
||||||
|
'url' => route('stories.show', ['slug' => $bookmark->story->slug]),
|
||||||
|
'saved_at' => $bookmark->created_at?->toIso8601String(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $items
|
||||||
|
->sortByDesc('saved_at')
|
||||||
|
->take($perPage)
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Story;
|
use App\Models\Story;
|
||||||
use App\Models\StoryTag;
|
use App\Models\StoryTag;
|
||||||
use App\Models\StoryAuthor;
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@@ -36,7 +36,7 @@ final class StoriesApiController extends Controller
|
|||||||
|
|
||||||
$stories = Cache::remember($cacheKey, 300, fn () =>
|
$stories = Cache::remember($cacheKey, 300, fn () =>
|
||||||
Story::published()
|
Story::published()
|
||||||
->with('author', 'tags')
|
->with('creator.profile', 'tags')
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->paginate($perPage, ['*'], 'page', $page)
|
->paginate($perPage, ['*'], 'page', $page)
|
||||||
);
|
);
|
||||||
@@ -60,7 +60,7 @@ final class StoriesApiController extends Controller
|
|||||||
{
|
{
|
||||||
$story = Cache::remember('stories:api:' . $slug, 600, fn () =>
|
$story = Cache::remember('stories:api:' . $slug, 600, fn () =>
|
||||||
Story::published()
|
Story::published()
|
||||||
->with('author', 'tags')
|
->with('creator.profile', 'tags')
|
||||||
->where('slug', $slug)
|
->where('slug', $slug)
|
||||||
->firstOrFail()
|
->firstOrFail()
|
||||||
);
|
);
|
||||||
@@ -76,7 +76,7 @@ final class StoriesApiController extends Controller
|
|||||||
{
|
{
|
||||||
$story = Cache::remember('stories:api:featured', 300, fn () =>
|
$story = Cache::remember('stories:api:featured', 300, fn () =>
|
||||||
Story::published()->featured()
|
Story::published()->featured()
|
||||||
->with('author', 'tags')
|
->with('creator.profile', 'tags')
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->first()
|
->first()
|
||||||
);
|
);
|
||||||
@@ -99,8 +99,8 @@ final class StoriesApiController extends Controller
|
|||||||
|
|
||||||
$stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
|
$stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
|
||||||
Story::published()
|
Story::published()
|
||||||
->with('author', 'tags')
|
->with('creator.profile', 'tags')
|
||||||
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
|
->whereHas('tags', fn ($q) => $q->where('story_tags.id', $storyTag->id))
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->paginate(12, ['*'], 'page', $page)
|
->paginate(12, ['*'], 'page', $page)
|
||||||
);
|
);
|
||||||
@@ -123,21 +123,20 @@ final class StoriesApiController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function byAuthor(Request $request, string $username): JsonResponse
|
public function byAuthor(Request $request, string $username): JsonResponse
|
||||||
{
|
{
|
||||||
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))->first()
|
$author = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)])->firstOrFail();
|
||||||
?? StoryAuthor::where('name', $username)->firstOrFail();
|
|
||||||
|
|
||||||
$page = (int) $request->get('page', 1);
|
$page = (int) $request->get('page', 1);
|
||||||
|
|
||||||
$stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () =>
|
$stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () =>
|
||||||
Story::published()
|
Story::published()
|
||||||
->with('author', 'tags')
|
->with('creator.profile', 'tags')
|
||||||
->where('author_id', $author->id)
|
->where('creator_id', $author->id)
|
||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->paginate(12, ['*'], 'page', $page)
|
->paginate(12, ['*'], 'page', $page)
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'author' => $this->formatAuthor($author),
|
'author' => $this->formatCreator($author),
|
||||||
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'current_page' => $stories->currentPage(),
|
'current_page' => $stories->currentPage(),
|
||||||
@@ -159,7 +158,7 @@ final class StoriesApiController extends Controller
|
|||||||
'title' => $story->title,
|
'title' => $story->title,
|
||||||
'excerpt' => $story->excerpt,
|
'excerpt' => $story->excerpt,
|
||||||
'cover_image' => $story->cover_url,
|
'cover_image' => $story->cover_url,
|
||||||
'author' => $story->author ? $this->formatAuthor($story->author) : null,
|
'author' => $story->creator ? $this->formatCreator($story->creator) : null,
|
||||||
'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]),
|
'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]),
|
||||||
'views' => $story->views,
|
'views' => $story->views,
|
||||||
'featured' => $story->featured,
|
'featured' => $story->featured,
|
||||||
@@ -175,14 +174,18 @@ final class StoriesApiController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatAuthor(StoryAuthor $author): array
|
private function formatCreator(User $creator): array
|
||||||
{
|
{
|
||||||
|
$avatarHash = $creator->profile?->avatar_hash;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $author->id,
|
'id' => $creator->id,
|
||||||
'name' => $author->name,
|
'name' => $creator->username ?? $creator->name,
|
||||||
'avatar_url' => $author->avatar_url,
|
'avatar_url' => $avatarHash
|
||||||
'bio' => $author->bio,
|
? \App\Support\AvatarUrl::forUser((int) $creator->id, $avatarHash, 96)
|
||||||
'profile_url' => $author->profile_url,
|
: \App\Support\AvatarUrl::default(),
|
||||||
|
'bio' => $creator->profile?->about,
|
||||||
|
'profile_url' => '/@' . strtolower((string) ($creator->username ?? $creator->id)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user