optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -26,7 +26,9 @@ class ArtworkFactory extends Factory
'width' => 800,
'height' => 600,
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_approved' => true,
'is_mature' => false,
'published_at' => now()->subDay(),
];
}
@@ -38,7 +40,7 @@ class ArtworkFactory extends Factory
public function private(): self
{
return $this->state(fn () => ['is_public' => false]);
return $this->state(fn () => ['is_public' => false, 'visibility' => Artwork::VISIBILITY_PRIVATE]);
}
public function unapproved(): self

View File

@@ -0,0 +1,105 @@
<?php
namespace Database\Factories;
use App\Models\Collection;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class CollectionFactory extends Factory
{
protected $model = Collection::class;
public function definition(): array
{
$title = $this->faker->unique()->sentence(3);
return [
'user_id' => User::factory(),
'managed_by_user_id' => null,
'title' => $title,
'slug' => Str::slug($title),
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
'workflow_state' => Collection::WORKFLOW_APPROVED,
'readiness_state' => Collection::READINESS_READY,
'health_state' => Collection::HEALTH_HEALTHY,
'health_flags_json' => [],
'canonical_collection_id' => null,
'duplicate_cluster_key' => null,
'program_key' => null,
'partner_key' => null,
'trust_tier' => 'standard',
'experiment_key' => null,
'recommendation_tier' => 'secondary',
'ranking_bucket' => 'steady',
'search_boost_tier' => 'standard',
'type' => Collection::TYPE_PERSONAL,
'editorial_owner_mode' => Collection::EDITORIAL_OWNER_CREATOR,
'editorial_owner_user_id' => null,
'editorial_owner_label' => null,
'description' => $this->faker->paragraph(),
'subtitle' => null,
'summary' => null,
'collaboration_mode' => Collection::COLLABORATION_CLOSED,
'allow_submissions' => false,
'allow_comments' => true,
'allow_saves' => true,
'moderation_status' => Collection::MODERATION_ACTIVE,
'visibility' => Collection::VISIBILITY_PUBLIC,
'mode' => Collection::MODE_MANUAL,
'sort_mode' => Collection::SORT_MANUAL,
'artworks_count' => 0,
'comments_count' => 0,
'is_featured' => false,
'profile_order' => null,
'views_count' => 0,
'likes_count' => 0,
'followers_count' => 0,
'shares_count' => 0,
'saves_count' => 0,
'collaborators_count' => 1,
'smart_rules_json' => null,
'layout_modules_json' => null,
'last_activity_at' => null,
'featured_at' => null,
'published_at' => null,
'unpublished_at' => null,
'event_key' => null,
'event_label' => null,
'season_key' => null,
'banner_text' => null,
'badge_label' => null,
'spotlight_style' => Collection::SPOTLIGHT_STYLE_DEFAULT,
'quality_score' => 52.5,
'ranking_score' => 61.0,
'metadata_completeness_score' => 72.0,
'editorial_readiness_score' => 68.0,
'freshness_score' => 64.0,
'engagement_score' => 28.0,
'health_score' => 66.0,
'last_health_check_at' => now(),
'last_recommendation_refresh_at' => now(),
'placement_eligibility' => true,
'analytics_enabled' => true,
'presentation_style' => Collection::PRESENTATION_STANDARD,
'emphasis_mode' => Collection::EMPHASIS_BALANCED,
'theme_token' => null,
'series_key' => null,
'series_title' => null,
'series_description' => null,
'series_order' => null,
'campaign_key' => null,
'campaign_label' => null,
'commercial_eligibility' => false,
'promotion_tier' => null,
'sponsorship_label' => null,
'partner_label' => null,
'monetization_ready_status' => null,
'brand_safe_status' => null,
'archived_at' => null,
'expired_at' => null,
'history_count' => 0,
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_activities', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('type', 32);
$table->string('entity_type', 48);
$table->unsignedBigInteger('entity_id');
$table->json('meta')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['user_id', 'created_at'], 'user_activities_user_created_idx');
$table->index(['user_id', 'type', 'created_at'], 'user_activities_user_type_created_idx');
$table->index(['entity_type', 'entity_id'], 'user_activities_entity_idx');
});
}
public function down(): void
{
Schema::dropIfExists('user_activities');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('user_activities', function (Blueprint $table): void {
$table->timestamp('hidden_at')->nullable()->after('created_at');
$table->foreignId('hidden_by')->nullable()->after('hidden_at')->constrained('users')->nullOnDelete();
$table->string('hidden_reason', 500)->nullable()->after('hidden_by');
$table->timestamp('flagged_at')->nullable()->after('hidden_reason');
$table->foreignId('flagged_by')->nullable()->after('flagged_at')->constrained('users')->nullOnDelete();
$table->string('flag_reason', 500)->nullable()->after('flagged_by');
$table->index(['hidden_at'], 'user_activities_hidden_at_idx');
$table->index(['flagged_at'], 'user_activities_flagged_at_idx');
});
}
public function down(): void
{
Schema::table('user_activities', function (Blueprint $table): void {
$table->dropIndex('user_activities_hidden_at_idx');
$table->dropIndex('user_activities_flagged_at_idx');
$table->dropConstrainedForeignId('hidden_by');
$table->dropConstrainedForeignId('flagged_by');
$table->dropColumn(['hidden_at', 'hidden_reason', 'flagged_at', 'flag_reason']);
});
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('user_follow_analytics')) {
return;
}
Schema::create('user_follow_analytics', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->unsignedInteger('followers_gained')->default(0);
$table->unsignedInteger('followers_lost')->default(0);
$table->unsignedInteger('follows_made')->default(0);
$table->unsignedInteger('unfollows_made')->default(0);
$table->timestamps();
$table->unique(['user_id', 'date']);
$table->index(['date', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('user_follow_analytics');
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('discovery_feedback_daily_metrics', function (Blueprint $table): void {
$table->id();
$table->date('metric_date');
$table->string('algo_version', 64);
$table->string('surface', 64);
$table->unsignedInteger('views')->default(0);
$table->unsignedInteger('clicks')->default(0);
$table->unsignedInteger('favorites')->default(0);
$table->unsignedInteger('downloads')->default(0);
$table->unsignedInteger('feedback_actions')->default(0);
$table->unsignedInteger('unique_users')->default(0);
$table->unsignedInteger('unique_artworks')->default(0);
$table->decimal('ctr', 8, 6)->default(0);
$table->decimal('favorite_rate_per_click', 8, 6)->default(0);
$table->decimal('download_rate_per_click', 8, 6)->default(0);
$table->decimal('feedback_rate_per_click', 8, 6)->default(0);
$table->timestamps();
$table->unique(['metric_date', 'algo_version', 'surface'], 'discovery_feedback_daily_unique_idx');
$table->index(['metric_date', 'algo_version', 'surface'], 'discovery_feedback_daily_lookup_idx');
});
}
public function down(): void
{
Schema::dropIfExists('discovery_feedback_daily_metrics');
}
};

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('artworks', function (Blueprint $table): void {
if (! Schema::hasColumn('artworks', 'trending_score_1h')) {
$table->float('trending_score_1h', 10, 4)->default(0)->after('is_approved')->index();
}
});
if (! Schema::hasTable('user_negative_signals')) {
Schema::create('user_negative_signals', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('signal_type', 24)->index();
$table->foreignId('artwork_id')->nullable()->constrained('artworks')->cascadeOnDelete();
$table->foreignId('tag_id')->nullable()->constrained('tags')->cascadeOnDelete();
$table->string('source', 64)->nullable();
$table->string('algo_version', 64)->nullable()->index();
$table->json('meta')->nullable();
$table->timestamps();
$table->unique(['user_id', 'signal_type', 'artwork_id'], 'user_negative_signals_user_artwork_unique');
$table->unique(['user_id', 'signal_type', 'tag_id'], 'user_negative_signals_user_tag_unique');
$table->index(['user_id', 'signal_type', 'created_at'], 'user_negative_signals_lookup_idx');
});
}
}
public function down(): void
{
Schema::dropIfExists('user_negative_signals');
Schema::table('artworks', function (Blueprint $table): void {
if (Schema::hasColumn('artworks', 'trending_score_1h')) {
$table->dropIndex(['trending_score_1h']);
$table->dropColumn('trending_score_1h');
}
});
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('discovery_feedback_daily_metrics', function (Blueprint $table): void {
$table->unsignedInteger('hidden_artworks')->default(0)->after('downloads');
$table->unsignedInteger('disliked_tags')->default(0)->after('hidden_artworks');
$table->unsignedInteger('undo_hidden_artworks')->default(0)->after('disliked_tags');
$table->unsignedInteger('undo_disliked_tags')->default(0)->after('undo_hidden_artworks');
$table->unsignedInteger('negative_feedback_actions')->default(0)->after('feedback_actions');
$table->unsignedInteger('undo_actions')->default(0)->after('negative_feedback_actions');
});
}
public function down(): void
{
Schema::table('discovery_feedback_daily_metrics', function (Blueprint $table): void {
$table->dropColumn([
'hidden_artworks',
'disliked_tags',
'undo_hidden_artworks',
'undo_disliked_tags',
'negative_feedback_actions',
'undo_actions',
]);
});
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('artworks', function (Blueprint $table): void {
if (! Schema::hasColumn('artworks', 'clip_tags_json')) {
$table->json('clip_tags_json')->nullable()->after('thumb_ext');
}
if (! Schema::hasColumn('artworks', 'blip_caption')) {
$table->text('blip_caption')->nullable()->after('clip_tags_json');
}
if (! Schema::hasColumn('artworks', 'yolo_objects_json')) {
$table->json('yolo_objects_json')->nullable()->after('blip_caption');
}
if (! Schema::hasColumn('artworks', 'vision_metadata_updated_at')) {
$table->timestamp('vision_metadata_updated_at')->nullable()->after('yolo_objects_json');
}
});
}
public function down(): void
{
Schema::table('artworks', function (Blueprint $table): void {
$columns = [];
foreach (['clip_tags_json', 'blip_caption', 'yolo_objects_json', 'vision_metadata_updated_at'] as $column) {
if (Schema::hasColumn('artworks', $column)) {
$columns[] = $column;
}
}
if ($columns !== []) {
$table->dropColumn($columns);
}
});
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('artworks', function (Blueprint $table): void {
$table->timestamp('last_vector_indexed_at')->nullable()->after('vision_metadata_updated_at')->index();
});
}
public function down(): void
{
Schema::table('artworks', function (Blueprint $table): void {
$table->dropIndex(['last_vector_indexed_at']);
$table->dropColumn('last_vector_indexed_at');
});
}
};

View File

@@ -0,0 +1,81 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const LEGACY_INDEX = 'messages_conversation_client_temp_idx';
private const UNIQUE_INDEX = 'messages_sender_client_temp_unique';
public function up(): void
{
if (! Schema::hasTable('messages') || ! Schema::hasColumn('messages', 'client_temp_id')) {
return;
}
DB::table('messages')
->where('client_temp_id', '')
->update(['client_temp_id' => null]);
$duplicates = DB::table('messages')
->select(['conversation_id', 'sender_id', 'client_temp_id'])
->whereNotNull('client_temp_id')
->groupBy('conversation_id', 'sender_id', 'client_temp_id')
->havingRaw('count(*) > 1')
->get();
foreach ($duplicates as $duplicate) {
$idsToClear = DB::table('messages')
->where('conversation_id', $duplicate->conversation_id)
->where('sender_id', $duplicate->sender_id)
->where('client_temp_id', $duplicate->client_temp_id)
->orderBy('id')
->pluck('id')
->slice(1)
->all();
if ($idsToClear === []) {
continue;
}
DB::table('messages')
->whereIn('id', $idsToClear)
->update(['client_temp_id' => null]);
}
Schema::table('messages', function (Blueprint $table): void {
$schema = Schema::getConnection()->getSchemaBuilder();
if ($schema->hasIndex('messages', self::LEGACY_INDEX)) {
$table->dropIndex(self::LEGACY_INDEX);
}
if (! $schema->hasIndex('messages', self::UNIQUE_INDEX)) {
$table->unique(['conversation_id', 'sender_id', 'client_temp_id'], self::UNIQUE_INDEX);
}
});
}
public function down(): void
{
if (! Schema::hasTable('messages') || ! Schema::hasColumn('messages', 'client_temp_id')) {
return;
}
Schema::table('messages', function (Blueprint $table): void {
$schema = Schema::getConnection()->getSchemaBuilder();
if ($schema->hasIndex('messages', self::UNIQUE_INDEX)) {
$table->dropUnique(self::UNIQUE_INDEX);
}
if (! $schema->hasIndex('messages', self::LEGACY_INDEX)) {
$table->index(['conversation_id', 'client_temp_id'], self::LEGACY_INDEX);
}
});
}
};

View File

@@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('collections', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete();
$table->string('title', 120);
$table->string('slug', 140);
$table->text('description')->nullable();
$table->foreignId('cover_artwork_id')
->nullable()
->constrained('artworks')
->nullOnDelete();
$table->enum('visibility', ['public', 'unlisted', 'private'])->default('public');
$table->enum('sort_mode', ['manual', 'newest', 'oldest', 'popular'])->default('manual');
$table->unsignedInteger('artworks_count')->default(0);
$table->boolean('is_featured')->default(false);
$table->timestamps();
$table->softDeletes();
$table->unique(['user_id', 'slug'], 'collections_user_slug_unique');
$table->index('user_id');
$table->index('visibility');
$table->index('created_at');
});
Schema::create('collection_artwork', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')
->constrained('collections')
->cascadeOnDelete();
$table->foreignId('artwork_id')
->constrained('artworks')
->cascadeOnDelete();
$table->unsignedInteger('order_num')->default(0);
$table->timestamps();
$table->unique(['collection_id', 'artwork_id'], 'collection_artwork_unique');
$table->index(['collection_id', 'order_num'], 'collection_artwork_order_idx');
$table->index('artwork_id');
});
}
public function down(): void
{
Schema::dropIfExists('collection_artwork');
Schema::dropIfExists('collections');
}
};

View File

@@ -0,0 +1,74 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->enum('mode', ['manual', 'smart'])->default('manual')->after('visibility');
$table->unsignedInteger('profile_order')->nullable()->after('is_featured');
$table->unsignedInteger('views_count')->default(0)->after('profile_order');
$table->unsignedInteger('likes_count')->default(0)->after('views_count');
$table->unsignedInteger('followers_count')->default(0)->after('likes_count');
$table->unsignedInteger('shares_count')->default(0)->after('followers_count');
$table->string('subtitle', 160)->nullable()->after('description');
$table->text('summary')->nullable()->after('subtitle');
$table->json('smart_rules_json')->nullable()->after('summary');
$table->timestamp('last_activity_at')->nullable()->after('smart_rules_json');
$table->timestamp('featured_at')->nullable()->after('last_activity_at');
$table->index(['user_id', 'is_featured', 'featured_at'], 'collections_user_featured_idx');
$table->index(['user_id', 'profile_order'], 'collections_user_profile_order_idx');
$table->index(['mode', 'visibility'], 'collections_mode_visibility_idx');
});
Schema::create('collection_follows', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['collection_id', 'user_id'], 'collection_follows_unique');
$table->index('user_id');
});
Schema::create('collection_likes', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['collection_id', 'user_id'], 'collection_likes_unique');
$table->index('user_id');
});
}
public function down(): void
{
Schema::dropIfExists('collection_likes');
Schema::dropIfExists('collection_follows');
Schema::table('collections', function (Blueprint $table): void {
$table->dropIndex('collections_user_featured_idx');
$table->dropIndex('collections_user_profile_order_idx');
$table->dropIndex('collections_mode_visibility_idx');
$table->dropColumn([
'mode',
'profile_order',
'views_count',
'likes_count',
'followers_count',
'shares_count',
'subtitle',
'summary',
'smart_rules_json',
'last_activity_at',
'featured_at',
]);
});
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('artworks', function (Blueprint $table): void {
if (! Schema::hasColumn('artworks', 'is_mature')) {
$table->boolean('is_mature')->default(false)->after('is_approved')->index();
}
});
}
public function down(): void
{
Schema::table('artworks', function (Blueprint $table): void {
if (Schema::hasColumn('artworks', 'is_mature')) {
$table->dropIndex(['is_mature']);
$table->dropColumn('is_mature');
}
});
}
};

View File

@@ -0,0 +1,98 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const CURRENT_TARGET_TYPES = [
'message',
'conversation',
'user',
'story',
'collection',
'collection_comment',
'collection_submission',
];
private const PREVIOUS_TARGET_TYPES = [
'message',
'conversation',
'user',
'story',
];
public function up(): void
{
$this->syncTargetTypes(self::CURRENT_TARGET_TYPES);
}
public function down(): void
{
$this->syncTargetTypes(self::PREVIOUS_TARGET_TYPES);
}
/**
* @param array<int, string> $targetTypes
*/
private function syncTargetTypes(array $targetTypes): void
{
if (! Schema::hasTable('reports') || ! Schema::hasColumn('reports', 'target_type')) {
return;
}
$driver = DB::getDriverName();
if ($driver === 'mysql') {
$allowed = implode("','", $targetTypes);
DB::statement("ALTER TABLE reports MODIFY target_type ENUM('{$allowed}') NOT NULL");
return;
}
if ($driver === 'sqlite') {
$this->rebuildSqliteReportsTable($targetTypes);
}
}
/**
* @param array<int, string> $targetTypes
*/
private function rebuildSqliteReportsTable(array $targetTypes): void
{
$quotedTargetTypes = collect($targetTypes)
->map(fn (string $type): string => "'{$type}'")
->implode(', ');
DB::statement('DROP TABLE IF EXISTS reports_temp');
DB::statement(
<<<SQL
CREATE TABLE reports_temp (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
reporter_id INTEGER NOT NULL,
target_type VARCHAR NOT NULL CHECK (target_type IN ({$quotedTargetTypes})),
target_id INTEGER NOT NULL,
reason VARCHAR(120) NOT NULL,
details TEXT NULL,
status VARCHAR NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'reviewing', 'closed')),
created_at DATETIME NULL,
updated_at DATETIME NULL,
FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE CASCADE
)
SQL
);
DB::statement(
'INSERT INTO reports_temp (id, reporter_id, target_type, target_id, reason, details, status, created_at, updated_at) '
.'SELECT id, reporter_id, target_type, target_id, reason, details, status, created_at, updated_at FROM reports'
);
DB::statement('DROP TABLE reports');
DB::statement('ALTER TABLE reports_temp RENAME TO reports');
DB::statement('CREATE INDEX reports_target_type_target_id_index ON reports (target_type, target_id)');
DB::statement('CREATE INDEX reports_status_index ON reports (status)');
DB::statement('CREATE INDEX reports_reporter_id_index ON reports (reporter_id)');
}
};

View File

@@ -0,0 +1,118 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->enum('type', ['personal', 'community', 'editorial'])->default('personal')->after('slug');
$table->enum('collaboration_mode', ['closed', 'invite_only', 'open'])->default('closed')->after('summary');
$table->boolean('allow_submissions')->default(false)->after('collaboration_mode');
$table->boolean('allow_comments')->default(true)->after('allow_submissions');
$table->boolean('allow_saves')->default(true)->after('allow_comments');
$table->enum('moderation_status', ['active', 'under_review', 'restricted', 'hidden'])->default('active')->after('allow_saves');
$table->unsignedInteger('comments_count')->default(0)->after('artworks_count');
$table->unsignedInteger('saves_count')->default(0)->after('shares_count');
$table->unsignedInteger('collaborators_count')->default(1)->after('saves_count');
$table->timestamp('published_at')->nullable()->after('featured_at');
$table->timestamp('unpublished_at')->nullable()->after('published_at');
$table->string('event_key', 80)->nullable()->after('unpublished_at');
$table->string('event_label', 120)->nullable()->after('event_key');
$table->string('season_key', 80)->nullable()->after('event_label');
$table->string('badge_label', 80)->nullable()->after('season_key');
$table->index(['type', 'visibility', 'moderation_status'], 'collections_type_visibility_mod_idx');
$table->index(['collaboration_mode', 'allow_submissions'], 'collections_collab_submission_idx');
});
Schema::create('collection_members', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('invited_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->enum('role', ['owner', 'editor', 'contributor', 'viewer'])->default('contributor');
$table->enum('status', ['pending', 'active', 'revoked'])->default('pending');
$table->string('note', 320)->nullable();
$table->timestamp('invited_at')->nullable();
$table->timestamp('accepted_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->unique(['collection_id', 'user_id'], 'collection_members_unique');
$table->index(['collection_id', 'status'], 'collection_members_status_idx');
});
Schema::create('collection_submissions', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->text('message')->nullable();
$table->enum('status', ['pending', 'approved', 'rejected', 'withdrawn'])->default('pending');
$table->foreignId('reviewed_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('reviewed_at')->nullable();
$table->timestamps();
$table->index(['collection_id', 'status'], 'collection_submissions_status_idx');
$table->unique(['collection_id', 'artwork_id', 'user_id'], 'collection_submissions_unique');
});
Schema::create('collection_saves', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['collection_id', 'user_id'], 'collection_saves_unique');
$table->index('user_id');
});
Schema::create('collection_comments', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('collection_comments')->cascadeOnDelete();
$table->text('body');
$table->text('rendered_body');
$table->enum('status', ['visible', 'hidden', 'flagged'])->default('visible');
$table->timestamps();
$table->softDeletes();
$table->index(['collection_id', 'status'], 'collection_comments_status_idx');
});
}
public function down(): void
{
Schema::dropIfExists('collection_comments');
Schema::dropIfExists('collection_saves');
Schema::dropIfExists('collection_submissions');
Schema::dropIfExists('collection_members');
Schema::table('collections', function (Blueprint $table): void {
$table->dropIndex('collections_type_visibility_mod_idx');
$table->dropIndex('collections_collab_submission_idx');
$table->dropColumn([
'type',
'collaboration_mode',
'allow_submissions',
'allow_comments',
'allow_saves',
'moderation_status',
'comments_count',
'saves_count',
'collaborators_count',
'published_at',
'unpublished_at',
'event_key',
'event_label',
'season_key',
'badge_label',
]);
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('collections') || ! Schema::hasColumn('collections', 'moderation_status')) {
return;
}
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE collections MODIFY moderation_status ENUM('active','under_review','restricted','hidden') NOT NULL DEFAULT 'active'");
}
}
public function down(): void
{
if (! Schema::hasTable('collections') || ! Schema::hasColumn('collections', 'moderation_status')) {
return;
}
if (DB::getDriverName() === 'mysql') {
DB::statement("UPDATE collections SET moderation_status = 'active' WHERE moderation_status IN ('under_review', 'restricted')");
DB::statement("ALTER TABLE collections MODIFY moderation_status ENUM('active','review','hidden') NOT NULL DEFAULT 'active'");
}
}
};

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->foreignId('managed_by_user_id')
->nullable()
->after('user_id')
->constrained('users')
->nullOnDelete();
$table->enum('editorial_owner_mode', ['creator', 'staff_account', 'system'])
->default('creator')
->after('type');
$table->foreignId('editorial_owner_user_id')
->nullable()
->after('managed_by_user_id')
->constrained('users')
->nullOnDelete();
$table->string('editorial_owner_label', 120)
->nullable()
->after('editorial_owner_user_id');
$table->json('layout_modules_json')
->nullable()
->after('smart_rules_json');
$table->index('managed_by_user_id', 'collections_managed_by_user_idx');
$table->index(['type', 'editorial_owner_mode'], 'collections_editorial_owner_mode_idx');
});
}
public function down(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->dropIndex('collections_managed_by_user_idx');
$table->dropIndex('collections_editorial_owner_mode_idx');
$table->dropConstrainedForeignId('managed_by_user_id');
$table->dropConstrainedForeignId('editorial_owner_user_id');
$table->dropColumn(['editorial_owner_mode', 'editorial_owner_label', 'layout_modules_json']);
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collection_members', function (Blueprint $table): void {
$table->timestamp('expires_at')->nullable()->after('invited_at');
$table->index('expires_at', 'collection_members_expires_at_idx');
});
}
public function down(): void
{
Schema::table('collection_members', function (Blueprint $table): void {
$table->dropIndex('collection_members_expires_at_idx');
$table->dropColumn('expires_at');
});
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->string('banner_text', 200)->nullable()->after('season_key');
$table->string('spotlight_style', 40)->default('default')->after('badge_label');
});
}
public function down(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->dropColumn([
'banner_text',
'spotlight_style',
]);
});
}
};

View File

@@ -0,0 +1,162 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->enum('lifecycle_state', ['draft', 'scheduled', 'published', 'featured', 'archived', 'hidden', 'restricted', 'under_review', 'expired'])
->default('draft')
->after('slug');
$table->decimal('quality_score', 6, 2)->nullable()->after('spotlight_style');
$table->decimal('ranking_score', 6, 2)->nullable()->after('quality_score');
$table->boolean('analytics_enabled')->default(true)->after('ranking_score');
$table->enum('presentation_style', ['standard', 'editorial_grid', 'hero_grid', 'masonry'])->default('standard')->after('analytics_enabled');
$table->enum('emphasis_mode', ['cover_heavy', 'balanced', 'artwork_first'])->default('balanced')->after('presentation_style');
$table->string('theme_token', 40)->nullable()->after('emphasis_mode');
$table->string('series_key', 80)->nullable()->after('theme_token');
$table->unsignedInteger('series_order')->nullable()->after('series_key');
$table->string('campaign_key', 80)->nullable()->after('series_order');
$table->string('campaign_label', 120)->nullable()->after('campaign_key');
$table->boolean('commercial_eligibility')->default(false)->after('campaign_label');
$table->string('promotion_tier', 40)->nullable()->after('commercial_eligibility');
$table->string('sponsorship_label', 120)->nullable()->after('promotion_tier');
$table->string('partner_label', 120)->nullable()->after('sponsorship_label');
$table->string('monetization_ready_status', 40)->nullable()->after('partner_label');
$table->string('brand_safe_status', 40)->nullable()->after('monetization_ready_status');
$table->timestamp('archived_at')->nullable()->after('unpublished_at');
$table->timestamp('expired_at')->nullable()->after('archived_at');
$table->unsignedInteger('history_count')->default(0)->after('expired_at');
$table->index(['lifecycle_state', 'moderation_status', 'visibility'], 'collections_lifecycle_visibility_idx');
$table->index(['series_key', 'series_order'], 'collections_series_idx');
$table->index(['campaign_key', 'lifecycle_state'], 'collections_campaign_lifecycle_idx');
});
Schema::create('collection_history', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('action_type', 80);
$table->string('summary', 255)->nullable();
$table->json('before_json')->nullable();
$table->json('after_json')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('collection_id');
$table->index('action_type');
$table->index('actor_user_id');
});
Schema::create('collection_surface_definitions', function (Blueprint $table): void {
$table->id();
$table->string('surface_key', 120)->unique();
$table->string('title', 120);
$table->string('description', 255)->nullable();
$table->enum('mode', ['manual', 'automatic', 'hybrid'])->default('manual');
$table->json('rules_json')->nullable();
$table->string('ranking_mode', 60)->nullable();
$table->unsignedInteger('max_items')->default(12);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('collection_surface_placements', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->string('surface_key', 120);
$table->string('placement_type', 60);
$table->unsignedInteger('priority')->default(0);
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->boolean('is_active')->default(true);
$table->string('campaign_key', 80)->nullable();
$table->text('notes')->nullable();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index('surface_key');
$table->index('starts_at');
$table->index('ends_at');
$table->index('is_active');
});
Schema::create('collection_daily_stats', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->date('stat_date');
$table->unsignedInteger('views_count')->default(0);
$table->unsignedInteger('likes_count')->default(0);
$table->unsignedInteger('follows_count')->default(0);
$table->unsignedInteger('saves_count')->default(0);
$table->unsignedInteger('comments_count')->default(0);
$table->unsignedInteger('shares_count')->default(0);
$table->unsignedInteger('submissions_count')->default(0);
$table->timestamps();
$table->unique(['collection_id', 'stat_date'], 'collection_daily_stats_unique');
});
Schema::create('collection_saved_lists', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('title', 120);
$table->string('slug', 140);
$table->timestamps();
$table->unique(['user_id', 'slug'], 'collection_saved_lists_user_slug_unique');
});
Schema::create('collection_saved_list_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('saved_list_id')->constrained('collection_saved_lists')->cascadeOnDelete();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->unsignedInteger('order_num')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->unique(['saved_list_id', 'collection_id'], 'collection_saved_list_items_unique');
});
}
public function down(): void
{
Schema::dropIfExists('collection_saved_list_items');
Schema::dropIfExists('collection_saved_lists');
Schema::dropIfExists('collection_daily_stats');
Schema::dropIfExists('collection_surface_placements');
Schema::dropIfExists('collection_surface_definitions');
Schema::dropIfExists('collection_history');
Schema::table('collections', function (Blueprint $table): void {
$table->dropIndex('collections_lifecycle_visibility_idx');
$table->dropIndex('collections_series_idx');
$table->dropIndex('collections_campaign_lifecycle_idx');
$table->dropColumn([
'lifecycle_state',
'quality_score',
'ranking_score',
'analytics_enabled',
'presentation_style',
'emphasis_mode',
'theme_token',
'series_key',
'series_order',
'campaign_key',
'campaign_label',
'commercial_eligibility',
'promotion_tier',
'sponsorship_label',
'partner_label',
'monetization_ready_status',
'brand_safe_status',
'archived_at',
'expired_at',
'history_count',
]);
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collection_surface_definitions', function (Blueprint $table): void {
$table->timestamp('starts_at')->nullable()->after('is_active');
$table->timestamp('ends_at')->nullable()->after('starts_at');
$table->string('fallback_surface_key', 120)->nullable()->after('ends_at');
$table->index('starts_at');
$table->index('ends_at');
$table->index('fallback_surface_key');
});
}
public function down(): void
{
Schema::table('collection_surface_definitions', function (Blueprint $table): void {
$table->dropIndex(['starts_at']);
$table->dropIndex(['ends_at']);
$table->dropIndex(['fallback_surface_key']);
$table->dropColumn(['starts_at', 'ends_at', 'fallback_surface_key']);
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('collection_related_links', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->foreignId('related_collection_id')->constrained('collections')->cascadeOnDelete();
$table->unsignedInteger('sort_order')->default(0);
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['collection_id', 'related_collection_id'], 'collection_related_links_unique');
$table->index(['collection_id', 'sort_order'], 'collection_related_links_sort_idx');
});
}
public function down(): void
{
Schema::dropIfExists('collection_related_links');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->string('series_title', 160)->nullable()->after('series_key');
$table->text('series_description')->nullable()->after('series_title');
});
}
public function down(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->dropColumn(['series_title', 'series_description']);
});
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->text('editorial_notes')->nullable()->after('brand_safe_status');
$table->text('staff_commercial_notes')->nullable()->after('editorial_notes');
});
}
public function down(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->dropColumn(['editorial_notes', 'staff_commercial_notes']);
});
}
};

View File

@@ -0,0 +1,162 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->string('workflow_state', 40)->nullable()->after('lifecycle_state');
$table->string('readiness_state', 40)->nullable()->after('workflow_state');
$table->string('health_state', 40)->nullable()->after('readiness_state');
$table->json('health_flags_json')->nullable()->after('health_state');
$table->foreignId('canonical_collection_id')->nullable()->after('health_flags_json')->constrained('collections')->nullOnDelete();
$table->string('duplicate_cluster_key', 120)->nullable()->after('canonical_collection_id');
$table->string('program_key', 80)->nullable()->after('duplicate_cluster_key');
$table->string('partner_key', 80)->nullable()->after('program_key');
$table->string('trust_tier', 40)->nullable()->after('partner_key');
$table->string('experiment_key', 80)->nullable()->after('trust_tier');
$table->string('recommendation_tier', 40)->nullable()->after('experiment_key');
$table->string('ranking_bucket', 40)->nullable()->after('recommendation_tier');
$table->string('search_boost_tier', 40)->nullable()->after('ranking_bucket');
$table->decimal('metadata_completeness_score', 6, 2)->nullable()->after('search_boost_tier');
$table->decimal('editorial_readiness_score', 6, 2)->nullable()->after('metadata_completeness_score');
$table->decimal('freshness_score', 6, 2)->nullable()->after('editorial_readiness_score');
$table->decimal('engagement_score', 6, 2)->nullable()->after('freshness_score');
$table->decimal('health_score', 6, 2)->nullable()->after('engagement_score');
$table->timestamp('last_health_check_at')->nullable()->after('health_score');
$table->timestamp('last_recommendation_refresh_at')->nullable()->after('last_health_check_at');
$table->boolean('placement_eligibility')->default(true)->after('last_recommendation_refresh_at');
$table->index(['workflow_state', 'readiness_state'], 'collections_workflow_readiness_idx');
$table->index(['health_state', 'placement_eligibility'], 'collections_health_eligibility_idx');
$table->index(['program_key', 'placement_eligibility'], 'collections_program_eligibility_idx');
$table->index(['partner_key', 'trust_tier'], 'collections_partner_trust_idx');
$table->index(['canonical_collection_id', 'duplicate_cluster_key'], 'collections_canonical_duplicate_idx');
});
Schema::create('collection_program_assignments', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->string('program_key', 80);
$table->string('campaign_key', 80)->nullable();
$table->string('placement_scope', 80)->nullable();
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->unsignedInteger('priority')->default(0);
$table->text('notes')->nullable();
$table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index(['program_key', 'priority'], 'collection_program_assignments_program_priority_idx');
$table->index(['campaign_key', 'placement_scope'], 'collection_program_assignments_campaign_scope_idx');
});
Schema::create('collection_quality_snapshots', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->date('snapshot_date');
$table->decimal('quality_score', 6, 2)->nullable();
$table->decimal('health_score', 6, 2)->nullable();
$table->decimal('metadata_completeness_score', 6, 2)->nullable();
$table->decimal('freshness_score', 6, 2)->nullable();
$table->decimal('engagement_score', 6, 2)->nullable();
$table->decimal('readiness_score', 6, 2)->nullable();
$table->json('flags_json')->nullable();
$table->timestamps();
$table->unique(['collection_id', 'snapshot_date'], 'collection_quality_snapshots_unique');
});
Schema::create('collection_recommendation_snapshots', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->string('context_key', 80);
$table->decimal('recommendation_score', 8, 2)->nullable();
$table->json('rationale_json')->nullable();
$table->date('snapshot_date');
$table->timestamps();
$table->index(['context_key', 'snapshot_date'], 'collection_recommendation_snapshots_context_date_idx');
});
Schema::create('collection_merge_actions', function (Blueprint $table): void {
$table->id();
$table->foreignId('source_collection_id')->constrained('collections')->cascadeOnDelete();
$table->foreignId('target_collection_id')->constrained('collections')->cascadeOnDelete();
$table->enum('action_type', ['suggested', 'approved', 'completed', 'rejected', 'reverted']);
$table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('summary', 255)->nullable();
$table->timestamps();
$table->index(['source_collection_id', 'target_collection_id'], 'collection_merge_actions_pair_idx');
});
Schema::create('collection_entity_links', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->string('linked_type', 80);
$table->unsignedBigInteger('linked_id');
$table->string('relationship_type', 80);
$table->json('metadata_json')->nullable();
$table->timestamps();
$table->index(['linked_type', 'linked_id'], 'collection_entity_links_target_idx');
$table->index(['collection_id', 'relationship_type'], 'collection_entity_links_relationship_idx');
});
Schema::create('collection_saved_notes', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete();
$table->text('note')->nullable();
$table->timestamps();
$table->unique(['user_id', 'collection_id'], 'collection_saved_notes_user_collection_unique');
});
}
public function down(): void
{
Schema::dropIfExists('collection_saved_notes');
Schema::dropIfExists('collection_entity_links');
Schema::dropIfExists('collection_merge_actions');
Schema::dropIfExists('collection_recommendation_snapshots');
Schema::dropIfExists('collection_quality_snapshots');
Schema::dropIfExists('collection_program_assignments');
Schema::table('collections', function (Blueprint $table): void {
$table->dropIndex('collections_workflow_readiness_idx');
$table->dropIndex('collections_health_eligibility_idx');
$table->dropIndex('collections_program_eligibility_idx');
$table->dropIndex('collections_partner_trust_idx');
$table->dropIndex('collections_canonical_duplicate_idx');
$table->dropConstrainedForeignId('canonical_collection_id');
$table->dropColumn([
'workflow_state',
'readiness_state',
'health_state',
'health_flags_json',
'duplicate_cluster_key',
'program_key',
'partner_key',
'trust_tier',
'experiment_key',
'recommendation_tier',
'ranking_bucket',
'search_boost_tier',
'metadata_completeness_score',
'editorial_readiness_score',
'freshness_score',
'engagement_score',
'health_score',
'last_health_check_at',
'last_recommendation_refresh_at',
'placement_eligibility',
]);
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->string('experiment_treatment', 80)->nullable()->after('experiment_key');
$table->string('placement_variant', 80)->nullable()->after('experiment_treatment');
$table->string('ranking_mode_variant', 80)->nullable()->after('placement_variant');
$table->string('collection_pool_version', 80)->nullable()->after('ranking_mode_variant');
$table->string('test_label', 120)->nullable()->after('collection_pool_version');
$table->index(['experiment_key', 'experiment_treatment'], 'collections_experiment_treatment_idx');
$table->index(['placement_variant', 'ranking_mode_variant'], 'collections_experiment_variants_idx');
});
}
public function down(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->dropIndex('collections_experiment_treatment_idx');
$table->dropIndex('collections_experiment_variants_idx');
$table->dropColumn([
'experiment_treatment',
'placement_variant',
'ranking_mode_variant',
'collection_pool_version',
'test_label',
]);
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->string('sponsorship_state', 40)->nullable()->after('sponsorship_label');
$table->string('ownership_domain', 80)->nullable()->after('partner_label');
$table->string('commercial_review_state', 40)->nullable()->after('ownership_domain');
$table->string('legal_review_state', 40)->nullable()->after('commercial_review_state');
$table->index('sponsorship_state');
$table->index('ownership_domain');
});
}
public function down(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->dropIndex(['sponsorship_state']);
$table->dropIndex(['ownership_domain']);
$table->dropColumn([
'sponsorship_state',
'ownership_domain',
'commercial_review_state',
'legal_review_state',
]);
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('collection_saves', function (Blueprint $table): void {
$table->timestamp('last_viewed_at')->nullable()->after('created_at');
$table->string('save_context', 80)->nullable()->after('last_viewed_at');
$table->json('save_context_meta_json')->nullable()->after('save_context');
$table->index(['user_id', 'last_viewed_at'], 'collection_saves_user_last_viewed_idx');
});
}
public function down(): void
{
Schema::table('collection_saves', function (Blueprint $table): void {
$table->dropIndex('collection_saves_user_last_viewed_idx');
$table->dropColumn([
'last_viewed_at',
'save_context',
'save_context_meta_json',
]);
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('nova_card_categories', function (Blueprint $table): void {
$table->id();
$table->string('slug', 120)->unique();
$table->string('name', 120);
$table->text('description')->nullable();
$table->boolean('active')->default(true);
$table->integer('order_num')->default(0);
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('nova_card_categories');
}
};

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('nova_card_templates', function (Blueprint $table): void {
$table->id();
$table->string('slug', 120)->unique();
$table->string('name', 120);
$table->text('description')->nullable();
$table->string('preview_image')->nullable();
$table->json('config_json');
$table->json('supported_formats');
$table->boolean('active')->default(true);
$table->boolean('official')->default(true);
$table->integer('order_num')->default(0);
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('nova_card_templates');
}
};

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('nova_card_tags', function (Blueprint $table): void {
$table->id();
$table->string('slug', 120)->unique();
$table->string('name', 120);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('nova_card_tags');
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('nova_card_backgrounds', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('original_path');
$table->string('processed_path')->nullable();
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
$table->string('mime_type', 120);
$table->unsignedBigInteger('file_size');
$table->string('sha256', 64)->nullable()->index();
$table->enum('visibility', ['private', 'card-only', 'public'])->default('card-only');
$table->timestamps();
$table->softDeletes();
$table->index(['user_id', 'visibility']);
});
}
public function down(): void
{
Schema::dropIfExists('nova_card_backgrounds');
}
};

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('nova_cards', function (Blueprint $table): void {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->nullable()->constrained('nova_card_categories')->nullOnDelete();
$table->string('title', 120);
$table->string('slug', 160);
$table->text('quote_text');
$table->string('quote_author', 160)->nullable();
$table->string('quote_source', 180)->nullable();
$table->text('description')->nullable();
$table->enum('format', ['square', 'portrait', 'story', 'landscape'])->default('square');
$table->json('project_json');
$table->unsignedInteger('render_version')->default(1);
$table->string('preview_path')->nullable();
$table->unsignedInteger('preview_width')->nullable();
$table->unsignedInteger('preview_height')->nullable();
$table->enum('background_type', ['gradient', 'upload', 'template', 'solid'])->default('gradient');
$table->foreignId('background_image_id')->nullable()->constrained('nova_card_backgrounds')->nullOnDelete();
$table->foreignId('template_id')->nullable()->constrained('nova_card_templates')->nullOnDelete();
$table->enum('visibility', ['public', 'unlisted', 'private'])->default('private');
$table->enum('status', ['draft', 'processing', 'published', 'hidden', 'rejected'])->default('draft');
$table->enum('moderation_status', ['pending', 'approved', 'flagged', 'rejected'])->default('pending');
$table->boolean('featured')->default(false);
$table->boolean('allow_download')->default(true);
$table->unsignedInteger('views_count')->default(0);
$table->unsignedInteger('shares_count')->default(0);
$table->unsignedInteger('downloads_count')->default(0);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index('user_id');
$table->index('status');
$table->index('moderation_status');
$table->index('category_id');
$table->index('published_at');
$table->index('featured');
$table->index(['visibility', 'status', 'published_at']);
});
Schema::create('nova_card_tag_relation', function (Blueprint $table): void {
$table->id();
$table->foreignId('card_id')->constrained('nova_cards')->cascadeOnDelete();
$table->foreignId('tag_id')->constrained('nova_card_tags')->cascadeOnDelete();
$table->timestamps();
$table->unique(['card_id', 'tag_id']);
});
}
public function down(): void
{
Schema::dropIfExists('nova_card_tag_relation');
Schema::dropIfExists('nova_cards');
}
};

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('nova_cards', function (Blueprint $table): void {
$table->unsignedSmallInteger('schema_version')->default(1)->after('project_json');
$table->foreignId('original_card_id')->nullable()->after('background_image_id')->constrained('nova_cards')->nullOnDelete();
$table->foreignId('root_card_id')->nullable()->after('original_card_id')->constrained('nova_cards')->nullOnDelete();
$table->boolean('allow_remix')->default(true)->after('allow_download');
$table->unsignedInteger('likes_count')->default(0)->after('downloads_count');
$table->unsignedInteger('favorites_count')->default(0)->after('likes_count');
$table->unsignedInteger('saves_count')->default(0)->after('favorites_count');
$table->unsignedInteger('remixes_count')->default(0)->after('saves_count');
$table->unsignedInteger('challenge_entries_count')->default(0)->after('remixes_count');
$table->timestamp('last_engaged_at')->nullable()->after('published_at');
$table->index('schema_version');
$table->index('original_card_id');
$table->index('root_card_id');
$table->index('likes_count');
$table->index('favorites_count');
$table->index('saves_count');
$table->index('remixes_count');
});
Schema::create('nova_card_reactions', function (Blueprint $table): void {
$table->id();
$table->foreignId('card_id')->constrained('nova_cards')->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('type', 24);
$table->timestamps();
$table->unique(['card_id', 'user_id', 'type']);
$table->index(['type', 'created_at']);
});
Schema::create('nova_card_collections', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('slug', 140);
$table->string('name', 120);
$table->text('description')->nullable();
$table->enum('visibility', ['private', 'public'])->default('private');
$table->boolean('official')->default(false);
$table->unsignedInteger('cards_count')->default(0);
$table->timestamps();
$table->unique(['user_id', 'slug']);
$table->index(['visibility', 'updated_at']);
});
Schema::create('nova_card_collection_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('collection_id')->constrained('nova_card_collections')->cascadeOnDelete();
$table->foreignId('card_id')->constrained('nova_cards')->cascadeOnDelete();
$table->text('note')->nullable();
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->unique(['collection_id', 'card_id']);
$table->index(['card_id', 'created_at']);
});
Schema::create('nova_card_versions', function (Blueprint $table): void {
$table->id();
$table->foreignId('card_id')->constrained('nova_cards')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->unsignedInteger('version_number');
$table->string('label', 120)->nullable();
$table->string('snapshot_hash', 64);
$table->json('snapshot_json');
$table->timestamps();
$table->unique(['card_id', 'version_number']);
$table->index(['card_id', 'created_at']);
$table->index('snapshot_hash');
});
Schema::create('nova_card_challenges', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('slug', 140)->unique();
$table->string('title', 140);
$table->text('description')->nullable();
$table->text('prompt')->nullable();
$table->json('rules_json')->nullable();
$table->enum('status', ['draft', 'active', 'completed', 'archived'])->default('draft');
$table->boolean('official')->default(false);
$table->boolean('featured')->default(false);
$table->foreignId('winner_card_id')->nullable()->constrained('nova_cards')->nullOnDelete();
$table->unsignedInteger('entries_count')->default(0);
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['status', 'featured', 'starts_at']);
});
Schema::create('nova_card_challenge_entries', function (Blueprint $table): void {
$table->id();
$table->foreignId('challenge_id')->constrained('nova_card_challenges')->cascadeOnDelete();
$table->foreignId('card_id')->constrained('nova_cards')->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('status', ['active', 'hidden', 'rejected', 'featured', 'winner', 'submitted'])->default('active');
$table->text('note')->nullable();
$table->timestamps();
$table->unique(['challenge_id', 'card_id']);
$table->index(['user_id', 'created_at']);
});
Schema::create('nova_card_asset_packs', function (Blueprint $table): void {
$table->id();
$table->string('slug', 140)->unique();
$table->string('name', 120);
$table->text('description')->nullable();
$table->enum('type', ['asset', 'template'])->default('asset');
$table->string('preview_image')->nullable();
$table->json('manifest_json')->nullable();
$table->boolean('official')->default(false);
$table->boolean('active')->default(true);
$table->unsignedInteger('order_num')->default(0);
$table->timestamps();
$table->index(['type', 'active', 'official', 'order_num']);
});
Schema::create('nova_card_assets', function (Blueprint $table): void {
$table->id();
$table->foreignId('asset_pack_id')->nullable()->constrained('nova_card_asset_packs')->nullOnDelete();
$table->string('asset_key', 80);
$table->string('label', 120);
$table->string('type', 32)->default('glyph');
$table->string('preview_image')->nullable();
$table->json('data_json')->nullable();
$table->boolean('official')->default(false);
$table->boolean('active')->default(true);
$table->unsignedInteger('order_num')->default(0);
$table->timestamps();
$table->unique(['asset_pack_id', 'asset_key']);
$table->index(['type', 'active', 'order_num']);
});
}
public function down(): void
{
Schema::dropIfExists('nova_card_assets');
Schema::dropIfExists('nova_card_asset_packs');
Schema::dropIfExists('nova_card_challenge_entries');
Schema::dropIfExists('nova_card_challenges');
Schema::dropIfExists('nova_card_versions');
Schema::dropIfExists('nova_card_collection_items');
Schema::dropIfExists('nova_card_collections');
Schema::dropIfExists('nova_card_reactions');
Schema::table('nova_cards', function (Blueprint $table): void {
$table->dropConstrainedForeignId('original_card_id');
$table->dropConstrainedForeignId('root_card_id');
$table->dropColumn([
'schema_version',
'allow_remix',
'likes_count',
'favorites_count',
'saves_count',
'remixes_count',
'challenge_entries_count',
'last_engaged_at',
]);
});
}
};

View File

@@ -0,0 +1,104 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const CURRENT_TARGET_TYPES = [
'message',
'conversation',
'user',
'story',
'collection',
'collection_comment',
'collection_submission',
'nova_card',
'nova_card_challenge',
'nova_card_challenge_entry',
];
private const PREVIOUS_TARGET_TYPES = [
'message',
'conversation',
'user',
'story',
'collection',
'collection_comment',
'collection_submission',
];
public function up(): void
{
$this->syncTargetTypes(self::CURRENT_TARGET_TYPES);
}
public function down(): void
{
$this->syncTargetTypes(self::PREVIOUS_TARGET_TYPES);
}
/**
* @param array<int, string> $targetTypes
*/
private function syncTargetTypes(array $targetTypes): void
{
if (! Schema::hasTable('reports') || ! Schema::hasColumn('reports', 'target_type')) {
return;
}
$driver = DB::getDriverName();
if ($driver === 'mysql') {
$allowed = implode("','", $targetTypes);
DB::statement("ALTER TABLE reports MODIFY target_type ENUM('{$allowed}') NOT NULL");
return;
}
if ($driver === 'sqlite') {
$this->rebuildSqliteReportsTable($targetTypes);
}
}
/**
* @param array<int, string> $targetTypes
*/
private function rebuildSqliteReportsTable(array $targetTypes): void
{
$quotedTargetTypes = collect($targetTypes)
->map(fn (string $type): string => "'{$type}'")
->implode(', ');
DB::statement('DROP TABLE IF EXISTS reports_temp');
DB::statement(
<<<SQL
CREATE TABLE reports_temp (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
reporter_id INTEGER NOT NULL,
target_type VARCHAR NOT NULL CHECK (target_type IN ({$quotedTargetTypes})),
target_id INTEGER NOT NULL,
reason VARCHAR(120) NOT NULL,
details TEXT NULL,
status VARCHAR NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'reviewing', 'closed')),
created_at DATETIME NULL,
updated_at DATETIME NULL,
FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE CASCADE
)
SQL
);
DB::statement(
'INSERT INTO reports_temp (id, reporter_id, target_type, target_id, reason, details, status, created_at, updated_at) '
.'SELECT id, reporter_id, target_type, target_id, reason, details, status, created_at, updated_at FROM reports'
);
DB::statement('DROP TABLE reports');
DB::statement('ALTER TABLE reports_temp RENAME TO reports');
DB::statement('CREATE INDEX reports_target_type_target_id_index ON reports (target_type, target_id)');
DB::statement('CREATE INDEX reports_status_index ON reports (status)');
DB::statement('CREATE INDEX reports_reporter_id_index ON reports (reporter_id)');
}
};

View File

@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('reports')) {
Schema::table('reports', function (Blueprint $table): void {
if (! Schema::hasColumn('reports', 'moderator_note')) {
$table->text('moderator_note')->nullable()->after('details');
}
if (! Schema::hasColumn('reports', 'last_moderated_by_id')) {
$table->foreignId('last_moderated_by_id')->nullable()->after('status')->constrained('users')->nullOnDelete();
}
if (! Schema::hasColumn('reports', 'last_moderated_at')) {
$table->timestamp('last_moderated_at')->nullable()->after('last_moderated_by_id');
}
});
}
if (Schema::hasTable('report_history')) {
return;
}
Schema::create('report_history', function (Blueprint $table): void {
$table->id();
$table->foreignId('report_id')->constrained('reports')->cascadeOnDelete();
$table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('action_type', 80);
$table->string('summary', 255)->nullable();
$table->text('note')->nullable();
$table->json('before_json')->nullable();
$table->json('after_json')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('report_id');
$table->index('action_type');
$table->index('actor_user_id');
});
}
public function down(): void
{
Schema::dropIfExists('report_history');
if (! Schema::hasTable('reports')) {
return;
}
Schema::table('reports', function (Blueprint $table): void {
if (Schema::hasColumn('reports', 'last_moderated_by_id')) {
$table->dropConstrainedForeignId('last_moderated_by_id');
}
if (Schema::hasColumn('reports', 'last_moderated_at')) {
$table->dropColumn('last_moderated_at');
}
if (Schema::hasColumn('reports', 'moderator_note')) {
$table->dropColumn('moderator_note');
}
});
}
};

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('nova_cards', function (Blueprint $table): void {
$table->decimal('trending_score', 12, 4)->default(0)->after('challenge_entries_count');
$table->index(['trending_score', 'published_at']);
});
}
public function down(): void
{
Schema::table('nova_cards', function (Blueprint $table): void {
$table->dropIndex(['trending_score', 'published_at']);
$table->dropColumn('trending_score');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('nova_card_comments', function (Blueprint $table): void {
$table->id();
$table->foreignId('card_id')->constrained('nova_cards')->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('nova_card_comments')->nullOnDelete();
$table->text('body');
$table->text('rendered_body')->nullable();
$table->string('status', 24)->default('visible');
$table->timestamps();
$table->softDeletes();
$table->index(['card_id', 'status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('nova_card_comments');
}
};

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('nova_cards', function (Blueprint $table): void {
$table->unsignedInteger('comments_count')->default(0)->after('remixes_count');
$table->index('comments_count');
});
Schema::table('nova_card_collections', function (Blueprint $table): void {
$table->boolean('featured')->default(false)->after('official');
$table->index(['featured', 'updated_at']);
});
DB::table('nova_card_challenge_entries')
->where('status', 'submitted')
->update(['status' => 'active']);
$driver = Schema::getConnection()->getDriverName();
if (in_array($driver, ['mysql', 'mariadb'], true)) {
DB::statement("ALTER TABLE nova_card_challenge_entries MODIFY status ENUM('active','hidden','rejected','featured','winner','submitted') NOT NULL DEFAULT 'active'");
}
}
public function down(): void
{
$driver = Schema::getConnection()->getDriverName();
if (in_array($driver, ['mysql', 'mariadb'], true)) {
DB::statement("ALTER TABLE nova_card_challenge_entries MODIFY status ENUM('submitted','featured','winner') NOT NULL DEFAULT 'submitted'");
}
DB::table('nova_card_challenge_entries')
->where('status', 'active')
->update(['status' => 'submitted']);
Schema::table('nova_card_collections', function (Blueprint $table): void {
$table->dropIndex(['featured', 'updated_at']);
$table->dropColumn('featured');
});
Schema::table('nova_cards', function (Blueprint $table): void {
$table->dropIndex(['comments_count']);
$table->dropColumn('comments_count');
});
}
};

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// v3 additions to nova_cards — guarded so a partially-applied migration can re-run safely
Schema::table('nova_cards', function (Blueprint $table): void {
if (! Schema::hasColumn('nova_cards', 'allow_background_reuse')) {
$table->boolean('allow_background_reuse')->default(true)->after('allow_remix');
}
if (! Schema::hasColumn('nova_cards', 'allow_export')) {
$table->boolean('allow_export')->default(true)->after('allow_background_reuse');
}
if (! Schema::hasColumn('nova_cards', 'style_family')) {
$table->string('style_family', 64)->nullable()->after('allow_export');
}
if (! Schema::hasColumn('nova_cards', 'palette_family')) {
$table->string('palette_family', 64)->nullable()->after('style_family');
}
if (! Schema::hasColumn('nova_cards', 'featured_score')) {
$table->decimal('featured_score', 12, 4)->nullable()->after('trending_score');
}
if (! Schema::hasColumn('nova_cards', 'density_score')) {
$table->tinyInteger('density_score')->unsigned()->nullable()->after('featured_score');
}
if (! Schema::hasColumn('nova_cards', 'editor_mode_last_used')) {
$table->string('editor_mode_last_used', 24)->nullable()->after('density_score');
}
if (! Schema::hasColumn('nova_cards', 'original_creator_id')) {
$table->foreignId('original_creator_id')->nullable()->after('root_card_id')->constrained('users')->nullOnDelete();
}
if (! Schema::hasColumn('nova_cards', 'last_ranked_at')) {
$table->timestamp('last_ranked_at')->nullable()->after('last_engaged_at');
}
if (! Schema::hasColumn('nova_cards', 'last_rendered_at')) {
$table->timestamp('last_rendered_at')->nullable()->after('last_ranked_at');
}
// Indexes — ignore errors if they already exist (handled below via DB::statement)
});
// Add indexes only if absent (guard against duplicate-index errors)
$hasIndex = static function (string $table, string $index): bool {
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
return collect(DB::select("PRAGMA index_list('{$table}')"))
->contains(static fn (object $row): bool => ($row->name ?? null) === $index);
}
return collect(DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$index]))->isNotEmpty();
};
if (! $hasIndex('nova_cards', 'nova_cards_style_family_index')) {
Schema::table('nova_cards', fn (Blueprint $t) => $t->index('style_family'));
}
if (! $hasIndex('nova_cards', 'nova_cards_palette_family_index')) {
Schema::table('nova_cards', fn (Blueprint $t) => $t->index('palette_family'));
}
if (! $hasIndex('nova_cards', 'nova_cards_featured_score_index')) {
Schema::table('nova_cards', fn (Blueprint $t) => $t->index('featured_score'));
}
if (! $hasIndex('nova_cards', 'nova_cards_original_creator_id_index')) {
Schema::table('nova_cards', fn (Blueprint $t) => $t->index('original_creator_id'));
}
// Creator presets table
if (! Schema::hasTable('nova_card_creator_presets')) {
Schema::create('nova_card_creator_presets', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name', 120);
$table->enum('preset_type', ['style', 'layout', 'background', 'typography', 'starter'])->default('style');
$table->json('config_json');
$table->boolean('is_default')->default(false);
$table->timestamps();
$table->softDeletes();
$table->index(['user_id', 'preset_type']);
$table->index(['user_id', 'is_default']);
});
}
// v3 enhancements to nova_card_collections
Schema::table('nova_card_collections', function (Blueprint $table): void {
if (! Schema::hasColumn('nova_card_collections', 'cover_card_id')) {
$table->unsignedBigInteger('cover_card_id')->nullable()->after('featured');
}
if (! Schema::hasColumn('nova_card_collections', 'cover_image_path')) {
$table->string('cover_image_path', 500)->nullable()->after('cover_card_id');
}
if (! Schema::hasColumn('nova_card_collections', 'intro_heading')) {
$table->string('intro_heading', 160)->nullable()->after('cover_image_path');
}
if (! Schema::hasColumn('nova_card_collections', 'sort_mode')) {
$table->string('sort_mode', 32)->default('manual')->after('intro_heading');
}
});
// v3 enhancements to nova_card_challenges
Schema::table('nova_card_challenges', function (Blueprint $table): void {
if (! Schema::hasColumn('nova_card_challenges', 'category')) {
$table->string('category', 80)->nullable()->after('featured');
}
if (! Schema::hasColumn('nova_card_challenges', 'series_slug')) {
$table->string('series_slug', 120)->nullable()->after('category');
}
if (! Schema::hasColumn('nova_card_challenges', 'edition_number')) {
$table->unsignedInteger('edition_number')->nullable()->after('series_slug');
}
if (! Schema::hasColumn('nova_card_challenges', 'cover_image_path')) {
$table->string('cover_image_path', 500)->nullable()->after('edition_number');
}
});
if (! $hasIndex('nova_card_challenges', 'nova_card_challenges_series_slug_index')) {
Schema::table('nova_card_challenges', fn (Blueprint $t) => $t->index('series_slug'));
}
if (! $hasIndex('nova_card_challenges', 'nova_card_challenges_category_index')) {
Schema::table('nova_card_challenges', fn (Blueprint $t) => $t->index('category'));
}
// Nova card export requests (for queued export generation)
if (! Schema::hasTable('nova_card_exports')) {
Schema::create('nova_card_exports', function (Blueprint $table): void {
$table->id();
$table->foreignId('card_id')->constrained('nova_cards')->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('export_type', ['preview', 'hires', 'square', 'story', 'wallpaper', 'og'])->default('preview');
$table->enum('status', ['pending', 'processing', 'ready', 'failed'])->default('pending');
$table->string('output_path', 500)->nullable();
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
$table->string('format', 12)->default('webp');
$table->json('options_json')->nullable();
$table->timestamp('ready_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->index(['card_id', 'status']);
$table->index(['user_id', 'created_at']);
});
}
}
public function down(): void
{
Schema::dropIfExists('nova_card_exports');
Schema::table('nova_card_challenges', function (Blueprint $table): void {
$table->dropIndex(['series_slug']);
$table->dropIndex(['category']);
$table->dropColumn(['category', 'series_slug', 'edition_number', 'cover_image_path']);
});
Schema::table('nova_card_collections', function (Blueprint $table): void {
$table->dropColumn(['cover_card_id', 'cover_image_path', 'intro_heading', 'sort_mode']);
});
Schema::dropIfExists('nova_card_creator_presets');
Schema::table('nova_cards', function (Blueprint $table): void {
$table->dropIndex(['style_family']);
$table->dropIndex(['palette_family']);
$table->dropIndex(['featured_score']);
$table->dropIndex(['original_creator_id']);
$table->dropForeign(['original_creator_id']);
$table->dropColumn([
'allow_background_reuse',
'allow_export',
'style_family',
'palette_family',
'featured_score',
'density_score',
'editor_mode_last_used',
'original_creator_id',
'last_ranked_at',
'last_rendered_at',
]);
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->boolean('nova_featured_creator')->default(false)->after('role');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('nova_featured_creator');
});
}
};

View File

@@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('artwork_ai_assists', function (Blueprint $table): void {
$table->id();
$table->foreignId('artwork_id')->unique()->constrained('artworks')->cascadeOnDelete();
$table->string('status', 24)->default('pending')->index();
$table->string('mode', 24)->nullable();
$table->json('title_suggestions_json')->nullable();
$table->json('description_suggestions_json')->nullable();
$table->json('tag_suggestions_json')->nullable();
$table->json('category_suggestions_json')->nullable();
$table->json('similar_candidates_json')->nullable();
$table->json('raw_response_json')->nullable();
$table->json('action_log_json')->nullable();
$table->text('error_message')->nullable();
$table->timestamp('processed_at')->nullable()->index();
$table->timestamps();
});
Schema::table('artworks', function (Blueprint $table): void {
if (! Schema::hasColumn('artworks', 'ai_status')) {
$table->string('ai_status', 24)->nullable()->after('last_vector_indexed_at');
}
if (! Schema::hasColumn('artworks', 'title_source')) {
$table->string('title_source', 24)->nullable()->after('ai_status');
}
if (! Schema::hasColumn('artworks', 'description_source')) {
$table->string('description_source', 24)->nullable()->after('title_source');
}
if (! Schema::hasColumn('artworks', 'tags_source')) {
$table->string('tags_source', 24)->nullable()->after('description_source');
}
if (! Schema::hasColumn('artworks', 'category_source')) {
$table->string('category_source', 24)->nullable()->after('tags_source');
}
});
}
public function down(): void
{
Schema::table('artworks', function (Blueprint $table): void {
$columns = [];
foreach (['ai_status', 'title_source', 'description_source', 'tags_source', 'category_source'] as $column) {
if (Schema::hasColumn('artworks', $column)) {
$columns[] = $column;
}
}
if ($columns !== []) {
$table->dropColumn($columns);
}
});
Schema::dropIfExists('artwork_ai_assists');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('artworks', function (Blueprint $table): void {
$table->string('visibility', 16)->default('public')->after('is_public');
$table->index('visibility');
});
DB::table('artworks')
->where('is_public', false)
->update(['visibility' => 'private']);
}
public function down(): void
{
Schema::table('artworks', function (Blueprint $table): void {
$table->dropIndex(['visibility']);
$table->dropColumn('visibility');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('artwork_ai_assist_events', function (Blueprint $table): void {
$table->id();
$table->foreignId('artwork_ai_assist_id')->nullable()->constrained('artwork_ai_assists')->nullOnDelete();
$table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('event_type', 64)->index();
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['artwork_id', 'created_at'], 'artwork_ai_assist_events_artwork_created_idx');
$table->index(['user_id', 'created_at'], 'artwork_ai_assist_events_user_created_idx');
});
}
public function down(): void
{
Schema::dropIfExists('artwork_ai_assist_events');
}
};

View File

@@ -2,6 +2,9 @@
namespace Database\Seeders;
use Database\Seeders\NovaCardCategorySeeder;
use Database\Seeders\NovaCardDemoSeeder;
use Database\Seeders\NovaCardTemplateSeeder;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -17,6 +20,12 @@ class DatabaseSeeder extends Seeder
{
// User::factory(10)->create();
$this->call([
NovaCardCategorySeeder::class,
NovaCardTemplateSeeder::class,
NovaCardDemoSeeder::class,
]);
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\NovaCardCategory;
use Illuminate\Database\Seeder;
class NovaCardCategorySeeder extends Seeder
{
public function run(): void
{
$categories = [
['slug' => 'motivation', 'name' => 'Motivation'],
['slug' => 'love', 'name' => 'Love'],
['slug' => 'life', 'name' => 'Life'],
['slug' => 'happiness', 'name' => 'Happiness'],
['slug' => 'healing', 'name' => 'Healing'],
['slug' => 'friendship', 'name' => 'Friendship'],
['slug' => 'aesthetic', 'name' => 'Aesthetic'],
['slug' => 'minimal', 'name' => 'Minimal'],
['slug' => 'dark-mood', 'name' => 'Dark Mood'],
['slug' => 'poetry', 'name' => 'Poetry'],
['slug' => 'wallpaper-quotes', 'name' => 'Wallpaper Quotes'],
];
foreach ($categories as $index => $category) {
NovaCardCategory::query()->updateOrCreate(
['slug' => $category['slug']],
[
'name' => $category['name'],
'description' => sprintf('%s cards and shareable quote visuals.', $category['name']),
'active' => true,
'order_num' => $index,
]
);
}
}
}

View File

@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\NovaCard;
use App\Models\NovaCardAsset;
use App\Models\NovaCardAssetPack;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardCollection;
use App\Models\NovaCardCollectionItem;
use App\Models\NovaCardTag;
use App\Models\NovaCardTemplate;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class NovaCardDemoSeeder extends Seeder
{
public function run(): void
{
if (! (bool) config('nova_cards.seed_demo_cards.enabled', false)) {
return;
}
$userConfig = (array) config('nova_cards.seed_demo_cards.user', []);
$user = User::query()->firstOrCreate(
['email' => (string) Arr::get($userConfig, 'email', 'nova-cards-demo@skinbase.test')],
[
'username' => (string) Arr::get($userConfig, 'username', 'nova.cards'),
'name' => (string) Arr::get($userConfig, 'name', 'Nova Cards'),
'password' => (string) Arr::get($userConfig, 'password', 'password'),
'role' => 'user',
]
);
$cards = [
[
'slug' => 'official-spark',
'title' => 'Official Spark',
'quote_text' => 'Small moments of focus turn into visible momentum.',
'quote_author' => 'Skinbase Nova',
'quote_source' => 'Launch Collection',
'description' => 'An official Nova Cards demo card for featured browse surfaces.',
'category_slug' => 'motivation',
'template_slug' => 'neon-nova',
'format' => NovaCard::FORMAT_SQUARE,
'featured' => true,
'tags' => ['focus', 'launch'],
'project_json' => [
'layout' => ['layout' => 'quote_heavy', 'position' => 'center', 'alignment' => 'center', 'padding' => 'comfortable', 'max_width' => 'balanced'],
'typography' => ['font_preset' => 'bold-poster', 'text_color' => '#e0f2fe', 'accent_color' => '#ffffff', 'quote_size' => 80, 'author_size' => 24, 'letter_spacing' => 1, 'line_height' => 1.05, 'shadow_preset' => 'strong'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'midnight-nova', 'gradient_colors' => ['#0f172a', '#1d4ed8'], 'solid_color' => '#111827', 'overlay_style' => 'dark-strong', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 65],
],
],
[
'slug' => 'soft-breath',
'title' => 'Soft Breath',
'quote_text' => 'Rest is not a pause from growth. It is part of it.',
'quote_author' => 'Skinbase Nova',
'quote_source' => 'Healing Notes',
'description' => 'A calm demo card showing the softer side of Nova Cards.',
'category_slug' => 'healing',
'template_slug' => 'soft-pastel',
'format' => NovaCard::FORMAT_PORTRAIT,
'featured' => false,
'tags' => ['healing', 'calm'],
'project_json' => [
'layout' => ['layout' => 'centered', 'position' => 'center', 'alignment' => 'center', 'padding' => 'airy', 'max_width' => 'compact'],
'typography' => ['font_preset' => 'soft-handwritten', 'text_color' => '#1f2937', 'accent_color' => '#6d28d9', 'quote_size' => 72, 'author_size' => 24, 'letter_spacing' => 0, 'line_height' => 1.2, 'shadow_preset' => 'none'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'soft-pastel', 'gradient_colors' => ['#f9a8d4', '#c4b5fd'], 'solid_color' => '#f5d0fe', 'overlay_style' => 'light-soft', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 35],
],
],
[
'slug' => 'night-echo',
'title' => 'Night Echo',
'quote_text' => 'Not every quiet room is empty. Some are full of answers.',
'quote_author' => 'Skinbase Nova',
'quote_source' => 'Dark Mood Study',
'description' => 'A darker official demo card for mood-oriented discovery blocks.',
'category_slug' => 'dark-mood',
'template_slug' => 'cinematic-dark',
'format' => NovaCard::FORMAT_LANDSCAPE,
'featured' => false,
'tags' => ['night', 'mood'],
'project_json' => [
'layout' => ['layout' => 'minimal', 'position' => 'lower-middle', 'alignment' => 'left', 'padding' => 'comfortable', 'max_width' => 'wide'],
'typography' => ['font_preset' => 'modern-sans', 'text_color' => '#f8fafc', 'accent_color' => '#93c5fd', 'quote_size' => 68, 'author_size' => 20, 'letter_spacing' => 1, 'line_height' => 1.2, 'shadow_preset' => 'soft'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'deep-cinema', 'gradient_colors' => ['#020617', '#334155'], 'solid_color' => '#020617', 'overlay_style' => 'dark-strong', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 80],
],
],
[
'slug' => 'editorial-glow',
'title' => 'Editorial Glow',
'quote_text' => 'Design with restraint, then let one accent do the speaking.',
'quote_author' => 'Skinbase Nova',
'quote_source' => 'Editorial Kit',
'description' => 'A crisp editorial-format demo card for official collections.',
'category_slug' => 'motivation',
'template_slug' => 'golden-serif',
'format' => NovaCard::FORMAT_PORTRAIT,
'featured' => true,
'tags' => ['editorial', 'gold'],
'project_json' => [
'layout' => ['layout' => 'author_emphasis', 'position' => 'center', 'alignment' => 'center', 'padding' => 'comfortable', 'max_width' => 'compact'],
'typography' => ['font_preset' => 'elegant-serif', 'text_color' => '#fffbeb', 'accent_color' => '#fbbf24', 'quote_size' => 76, 'author_size' => 24, 'letter_spacing' => 1, 'line_height' => 1.15, 'shadow_preset' => 'soft'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'amber-glow', 'gradient_colors' => ['#451a03', '#b45309'], 'solid_color' => '#451a03', 'overlay_style' => 'dark-soft', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 70],
],
],
[
'slug' => 'story-bloom',
'title' => 'Story Bloom',
'quote_text' => 'If the layout breathes, the words can reach further.',
'quote_author' => 'Skinbase Nova',
'quote_source' => 'Story Vertical Pack',
'description' => 'A vertical story-oriented demo card for public browsing and challenges.',
'category_slug' => 'healing',
'template_slug' => 'story-vertical',
'format' => NovaCard::FORMAT_STORY,
'featured' => false,
'tags' => ['story', 'vertical'],
'project_json' => [
'layout' => ['layout' => 'centered', 'position' => 'center', 'alignment' => 'center', 'padding' => 'airy', 'max_width' => 'balanced'],
'typography' => ['font_preset' => 'modern-sans', 'text_color' => '#ecfdf5', 'accent_color' => '#6ee7b7', 'quote_size' => 82, 'author_size' => 24, 'letter_spacing' => 0, 'line_height' => 1.18, 'shadow_preset' => 'soft'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'emerald-bloom', 'gradient_colors' => ['#064e3b', '#10b981'], 'solid_color' => '#022c22', 'overlay_style' => 'dark-soft', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 65],
],
],
[
'slug' => 'remix-launch-variant',
'title' => 'Remix Launch Variant',
'quote_text' => 'Take the spark and give it a new rhythm.',
'quote_author' => 'Skinbase Nova',
'quote_source' => 'Remix Lab',
'description' => 'A seeded remix showing lineage in demo content.',
'category_slug' => 'motivation',
'template_slug' => 'bold-statement',
'format' => NovaCard::FORMAT_SQUARE,
'featured' => false,
'tags' => ['remix', 'launch'],
'project_json' => [
'layout' => ['layout' => 'quote_heavy', 'position' => 'center', 'alignment' => 'left', 'padding' => 'comfortable', 'max_width' => 'wide'],
'typography' => ['font_preset' => 'bold-poster', 'text_color' => '#ffffff', 'accent_color' => '#38bdf8', 'quote_size' => 80, 'author_size' => 22, 'letter_spacing' => 1, 'line_height' => 1.05, 'shadow_preset' => 'strong'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'midnight-nova', 'gradient_colors' => ['#082f49', '#1d4ed8'], 'solid_color' => '#0f172a', 'overlay_style' => 'dark-strong', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 75],
],
],
];
$seededCards = collect();
foreach ($cards as $index => $definition) {
$category = NovaCardCategory::query()->where('slug', $definition['category_slug'])->first();
$template = NovaCardTemplate::query()->where('slug', $definition['template_slug'])->first();
$card = NovaCard::query()->updateOrCreate(
['slug' => $definition['slug']],
[
'uuid' => NovaCard::query()->where('slug', $definition['slug'])->value('uuid') ?: (string) Str::uuid(),
'user_id' => $user->id,
'category_id' => $category?->id,
'template_id' => $template?->id,
'title' => $definition['title'],
'quote_text' => $definition['quote_text'],
'quote_author' => $definition['quote_author'],
'quote_source' => $definition['quote_source'],
'description' => $definition['description'],
'format' => $definition['format'],
'project_json' => [
'content' => [
'title' => $definition['title'],
'quote_text' => $definition['quote_text'],
'quote_author' => $definition['quote_author'],
'quote_source' => $definition['quote_source'],
],
...$definition['project_json'],
'decorations' => [],
],
'render_version' => 1,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'featured' => (bool) $definition['featured'],
'allow_download' => true,
'views_count' => 25 - ($index * 4),
'shares_count' => 6 - $index,
'downloads_count' => 3 - min($index, 2),
'likes_count' => max(1, 8 - $index),
'favorites_count' => max(0, 5 - $index),
'saves_count' => max(0, 4 - $index),
'published_at' => now()->subDays($index + 1),
]
);
$tagIds = collect($definition['tags'])
->map(function (string $tag): int {
$model = NovaCardTag::query()->firstOrCreate(
['slug' => Str::slug($tag)],
['name' => Str::title(str_replace('-', ' ', $tag))]
);
return (int) $model->id;
})
->all();
$card->tags()->sync($tagIds);
$seededCards->push($card->fresh());
}
$source = $seededCards->firstWhere('slug', 'official-spark');
$remix = $seededCards->firstWhere('slug', 'remix-launch-variant');
if ($source && $remix) {
$remix->forceFill([
'original_card_id' => $source->id,
'root_card_id' => $source->id,
'remixes_count' => 0,
])->save();
$source->forceFill([
'remixes_count' => 1,
])->save();
}
foreach (array_merge(
(array) config('nova_cards.asset_packs', []),
(array) config('nova_cards.template_packs', [])
) as $index => $packConfig) {
$pack = NovaCardAssetPack::query()->updateOrCreate(
['slug' => (string) ($packConfig['slug'] ?? ('pack-' . $index))],
[
'name' => (string) ($packConfig['name'] ?? 'Nova Card Pack'),
'description' => $packConfig['description'] ?? null,
'type' => (string) ($packConfig['type'] ?? NovaCardAssetPack::TYPE_ASSET),
'manifest_json' => $packConfig['manifest_json'] ?? [],
'official' => (bool) ($packConfig['official'] ?? true),
'active' => (bool) ($packConfig['active'] ?? true),
'order_num' => $index,
]
);
foreach ((array) data_get($packConfig, 'manifest_json.items', []) as $itemIndex => $item) {
NovaCardAsset::query()->updateOrCreate(
['asset_pack_id' => $pack->id, 'asset_key' => (string) ($item['key'] ?? ('asset-' . $itemIndex))],
[
'label' => (string) ($item['label'] ?? 'Nova Asset'),
'type' => (string) ($item['type'] ?? 'glyph'),
'preview_image' => $item['preview_image'] ?? null,
'data_json' => $item,
'official' => true,
'active' => true,
'order_num' => $itemIndex,
]
);
}
}
$collection = NovaCardCollection::query()->updateOrCreate(
['user_id' => $user->id, 'slug' => 'editorial-favorites'],
[
'name' => 'Editorial Favorites',
'description' => 'Officially curated Nova Cards spotlighting launch visuals, remixes, and story-first layouts.',
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
'official' => true,
'featured' => true,
'cards_count' => 0,
]
);
$challengeCollection = NovaCardCollection::query()->updateOrCreate(
['user_id' => $user->id, 'slug' => 'challenge-winners'],
[
'name' => 'Challenge Winners',
'description' => 'Official challenge highlights and standout seeded examples.',
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
'official' => true,
'featured' => false,
'cards_count' => 0,
]
);
foreach ($seededCards->take(4)->values() as $position => $card) {
NovaCardCollectionItem::query()->updateOrCreate(
['collection_id' => $collection->id, 'card_id' => $card->id],
['sort_order' => $position + 1, 'note' => $position === 0 ? 'Launch anchor card for the collection.' : null],
);
}
$collection->forceFill(['cards_count' => NovaCardCollectionItem::query()->where('collection_id', $collection->id)->count()])->save();
foreach ($seededCards->slice(2, 4)->values() as $position => $card) {
NovaCardCollectionItem::query()->updateOrCreate(
['collection_id' => $challengeCollection->id, 'card_id' => $card->id],
['sort_order' => $position + 1, 'note' => $position === 0 ? 'Seeded challenge highlight.' : null],
);
}
$challengeCollection->forceFill(['cards_count' => NovaCardCollectionItem::query()->where('collection_id', $challengeCollection->id)->count()])->save();
$challenge = NovaCardChallenge::query()->updateOrCreate(
['slug' => 'launch-remix-run'],
[
'user_id' => $user->id,
'title' => 'Launch Remix Run',
'description' => 'A seeded official challenge for remix-ready launch cards.',
'prompt' => 'Remix one official card and push the layout into a fresh direction.',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => true,
'starts_at' => now()->subDays(2),
'ends_at' => now()->addDays(10),
]
);
$secondaryChallenge = NovaCardChallenge::query()->updateOrCreate(
['slug' => 'minimal-poster-week'],
[
'user_id' => $user->id,
'title' => 'Minimal Poster Week',
'description' => 'A seeded editorial challenge focused on restraint and poster typography.',
'prompt' => 'Build a bold quote poster with minimal decoration and strong hierarchy.',
'status' => NovaCardChallenge::STATUS_COMPLETED,
'official' => true,
'featured' => false,
'starts_at' => now()->subDays(14),
'ends_at' => now()->subDays(3),
]
);
foreach ($seededCards->take(3)->values() as $position => $card) {
NovaCardChallengeEntry::query()->updateOrCreate(
['challenge_id' => $challenge->id, 'card_id' => $card->id],
['user_id' => $user->id, 'status' => $position === 0 ? 'featured' : 'active', 'note' => $position === 0 ? 'Seeded featured entry.' : null],
);
}
foreach ($seededCards->slice(3, 3)->values() as $position => $card) {
NovaCardChallengeEntry::query()->updateOrCreate(
['challenge_id' => $secondaryChallenge->id, 'card_id' => $card->id],
['user_id' => $user->id, 'status' => $position === 0 ? 'winner' : 'active', 'note' => $position === 0 ? 'Seeded winner entry.' : null],
);
}
$challenge->forceFill([
'entries_count' => NovaCardChallengeEntry::query()->where('challenge_id', $challenge->id)->count(),
'winner_card_id' => $seededCards->first()?->id,
])->save();
$secondaryChallenge->forceFill([
'entries_count' => NovaCardChallengeEntry::query()->where('challenge_id', $secondaryChallenge->id)->count(),
'winner_card_id' => $seededCards->get(3)?->id,
])->save();
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\NovaCardTemplate;
use Illuminate\Database\Seeder;
class NovaCardTemplateSeeder extends Seeder
{
public function run(): void
{
$templates = [
[
'slug' => 'minimal-black-white',
'name' => 'Minimal Black / White',
'description' => 'High-contrast minimalist typography.',
'formats' => ['square', 'portrait', 'landscape'],
'config' => ['font_preset' => 'minimal-editorial', 'gradient_preset' => 'deep-cinema', 'text_align' => 'left', 'layout' => 'minimal', 'text_color' => '#ffffff', 'overlay_style' => 'dark-soft'],
],
[
'slug' => 'neon-nova',
'name' => 'Neon Nova',
'description' => 'Bright accents with deep night gradients.',
'formats' => ['square', 'portrait', 'story'],
'config' => ['font_preset' => 'bold-poster', 'gradient_preset' => 'midnight-nova', 'text_align' => 'center', 'layout' => 'centered', 'text_color' => '#e0f2fe', 'overlay_style' => 'dark-strong'],
],
[
'slug' => 'soft-pastel',
'name' => 'Soft Pastel',
'description' => 'Soft pastel tones for uplifting cards.',
'formats' => ['square', 'portrait'],
'config' => ['font_preset' => 'soft-handwritten', 'gradient_preset' => 'soft-pastel', 'text_align' => 'center', 'layout' => 'quote_heavy', 'text_color' => '#1f2937', 'overlay_style' => 'light-soft'],
],
[
'slug' => 'romantic',
'name' => 'Romantic',
'description' => 'Warm romantic quote treatments.',
'formats' => ['square', 'portrait', 'story'],
'config' => ['font_preset' => 'elegant-serif', 'gradient_preset' => 'romantic-dusk', 'text_align' => 'center', 'layout' => 'author_emphasis', 'text_color' => '#fff1f2', 'overlay_style' => 'dark-soft'],
],
[
'slug' => 'cinematic-dark',
'name' => 'Cinematic Dark',
'description' => 'Moody cinematic composition.',
'formats' => ['square', 'portrait', 'landscape'],
'config' => ['font_preset' => 'modern-sans', 'gradient_preset' => 'deep-cinema', 'text_align' => 'left', 'layout' => 'quote_heavy', 'text_color' => '#f8fafc', 'overlay_style' => 'dark-strong'],
],
[
'slug' => 'golden-serif',
'name' => 'Golden Serif',
'description' => 'Elegant serif layout with warm highlights.',
'formats' => ['square', 'portrait', 'landscape'],
'config' => ['font_preset' => 'elegant-serif', 'gradient_preset' => 'amber-glow', 'text_align' => 'center', 'layout' => 'author_emphasis', 'text_color' => '#fffbeb', 'overlay_style' => 'dark-soft'],
],
[
'slug' => 'nature-calm',
'name' => 'Nature Calm',
'description' => 'Fresh greens and sky tones.',
'formats' => ['square', 'portrait', 'story'],
'config' => ['font_preset' => 'dreamy-aesthetic', 'gradient_preset' => 'nature-calm', 'text_align' => 'left', 'layout' => 'centered', 'text_color' => '#ecfeff', 'overlay_style' => 'dark-soft'],
],
[
'slug' => 'bold-statement',
'name' => 'Bold Statement',
'description' => 'Large statement typography.',
'formats' => ['square', 'portrait', 'landscape'],
'config' => ['font_preset' => 'bold-poster', 'gradient_preset' => 'midnight-nova', 'text_align' => 'left', 'layout' => 'quote_heavy', 'text_color' => '#ffffff', 'overlay_style' => 'dark-strong'],
],
[
'slug' => 'wallpaper-style',
'name' => 'Wallpaper Style',
'description' => 'Spacious wallpaper-like composition.',
'formats' => ['portrait', 'story', 'landscape'],
'config' => ['font_preset' => 'dreamy-aesthetic', 'gradient_preset' => 'dream-glow', 'text_align' => 'center', 'layout' => 'minimal', 'text_color' => '#fdf4ff', 'overlay_style' => 'dark-soft'],
],
[
'slug' => 'story-vertical',
'name' => 'Story Vertical',
'description' => 'Tall mobile-first story layout.',
'formats' => ['story'],
'config' => ['font_preset' => 'modern-sans', 'gradient_preset' => 'emerald-bloom', 'text_align' => 'center', 'layout' => 'centered', 'text_color' => '#ecfdf5', 'overlay_style' => 'dark-soft'],
],
[
'slug' => 'dream-glow',
'name' => 'Dream Glow',
'description' => 'Glowing dreamy aesthetic template.',
'formats' => ['square', 'portrait', 'story'],
'config' => ['font_preset' => 'dreamy-aesthetic', 'gradient_preset' => 'dream-glow', 'text_align' => 'center', 'layout' => 'quote_heavy', 'text_color' => '#faf5ff', 'overlay_style' => 'dark-soft'],
],
[
'slug' => 'classic-typography',
'name' => 'Classic Typography',
'description' => 'Classic bookish quote treatment.',
'formats' => ['square', 'portrait', 'landscape'],
'config' => ['font_preset' => 'elegant-serif', 'gradient_preset' => 'amber-glow', 'text_align' => 'left', 'layout' => 'author_emphasis', 'text_color' => '#fff7ed', 'overlay_style' => 'dark-soft'],
],
];
foreach ($templates as $index => $template) {
NovaCardTemplate::query()->updateOrCreate(
['slug' => $template['slug']],
[
'name' => $template['name'],
'description' => $template['description'],
'preview_image' => null,
'config_json' => $template['config'],
'supported_formats' => $template['formats'],
'active' => true,
'official' => true,
'order_num' => $index,
]
);
}
}
}