Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -12,7 +12,7 @@ return new class extends Migration {
$table->foreignId('user_id')->index();
$table->string('title', 150);
$table->string('slug', 160)->unique();
$table->string('slug', 160)->index();
$table->text('description')->nullable();
$table->string('file_name');

View File

@@ -13,20 +13,22 @@ return new class extends Migration
{
if (!Schema::hasTable('forum_posts')) {
Schema::create('forum_posts', function (Blueprint $table) {
$table->id();
$table->id();
$table->foreignId('thread_id')->constrained('forum_threads')->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Added in a follow-up migration because forum_threads is created
// later in filename order even though it shares the same timestamp.
$table->unsignedBigInteger('thread_id');
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->longText('content');
$table->longText('content');
$table->boolean('is_edited')->default(false);
$table->timestamp('edited_at')->nullable();
$table->boolean('is_edited')->default(false);
$table->timestamp('edited_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->timestamps();
$table->softDeletes();
$table->index(['thread_id','created_at']);
$table->index(['thread_id', 'created_at']);
});
}
}

View File

@@ -0,0 +1,104 @@
<?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
{
if (Schema::getConnection()->getDriverName() !== 'mysql') {
return;
}
if (!Schema::hasTable('forum_posts') || !Schema::hasTable('forum_threads')) {
return;
}
if ($this->foreignKeyExists('forum_posts', 'forum_posts_thread_id_foreign')) {
return;
}
$this->deleteOrphanedForumPosts();
Schema::table('forum_posts', function (Blueprint $table) {
$table->foreign('thread_id', 'forum_posts_thread_id_foreign')
->references('id')
->on('forum_threads')
->cascadeOnDelete();
});
}
public function down(): void
{
if (Schema::getConnection()->getDriverName() !== 'mysql') {
return;
}
if (!Schema::hasTable('forum_posts')) {
return;
}
if (! $this->foreignKeyExists('forum_posts', 'forum_posts_thread_id_foreign')) {
return;
}
Schema::table('forum_posts', function (Blueprint $table) {
$table->dropForeign('forum_posts_thread_id_foreign');
});
}
private function foreignKeyExists(string $tableName, string $constraintName): bool
{
$connection = Schema::getConnection();
$driver = $connection->getDriverName();
if ($driver === 'mysql') {
$databaseName = $connection->getDatabaseName();
return DB::table('information_schema.table_constraints')
->where('constraint_schema', $databaseName)
->where('table_name', $tableName)
->where('constraint_name', $constraintName)
->where('constraint_type', 'FOREIGN KEY')
->exists();
}
return false;
}
private function deleteOrphanedForumPosts(): void
{
$orphanedPostIds = DB::table('forum_posts')
->leftJoin('forum_threads', 'forum_threads.id', '=', 'forum_posts.thread_id')
->whereNull('forum_threads.id')
->pluck('forum_posts.id');
if ($orphanedPostIds->isEmpty()) {
return;
}
if (Schema::hasTable('forum_attachments')) {
DB::table('forum_attachments')
->whereIn('post_id', $orphanedPostIds)
->delete();
}
if (Schema::hasTable('forum_post_reports')) {
DB::table('forum_post_reports')
->whereIn('post_id', $orphanedPostIds)
->orWhereIn('thread_id', function ($query) {
$query->select('forum_posts.thread_id')
->from('forum_posts')
->leftJoin('forum_threads', 'forum_threads.id', '=', 'forum_posts.thread_id')
->whereNull('forum_threads.id');
})
->delete();
}
DB::table('forum_posts')
->whereIn('id', $orphanedPostIds)
->delete();
}
};

View File

@@ -8,24 +8,26 @@ return new class extends Migration
{
public function up(): void
{
Schema::create('blog_posts', function (Blueprint $table) {
$table->id();
$table->string('slug', 191)->unique();
$table->string('title', 255);
$table->mediumText('body');
$table->text('excerpt')->nullable();
$table->foreignId('author_id')->nullable()
->constrained('users')->nullOnDelete();
$table->string('featured_image', 500)->nullable();
$table->string('meta_title', 255)->nullable();
$table->text('meta_description')->nullable();
$table->boolean('is_published')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
if (! Schema::hasTable('blog_posts')) {
Schema::create('blog_posts', function (Blueprint $table) {
$table->id();
$table->string('slug', 191)->unique();
$table->string('title', 255);
$table->mediumText('body');
$table->text('excerpt')->nullable();
$table->foreignId('author_id')->nullable()
->constrained('users')->nullOnDelete();
$table->string('featured_image', 500)->nullable();
$table->string('meta_title', 255)->nullable();
$table->text('meta_description')->nullable();
$table->boolean('is_published')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['is_published', 'published_at']);
});
$table->index(['is_published', 'published_at']);
});
}
}
public function down(): void

View File

@@ -0,0 +1,42 @@
<?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 {
if (! Schema::hasColumn('nova_cards', 'scheduled_for')) {
$table->timestamp('scheduled_for')->nullable()->after('published_at');
}
if (! Schema::hasColumn('nova_cards', 'scheduling_timezone')) {
$table->string('scheduling_timezone', 64)->nullable()->after('scheduled_for');
}
});
Schema::table('nova_cards', function (Blueprint $table): void {
$table->index(['status', 'scheduled_for'], 'nova_cards_status_scheduled_for_idx');
});
}
public function down(): void
{
Schema::table('nova_cards', function (Blueprint $table): void {
$table->dropIndex('nova_cards_status_scheduled_for_idx');
if (Schema::hasColumn('nova_cards', 'scheduling_timezone')) {
$table->dropColumn('scheduling_timezone');
}
if (Schema::hasColumn('nova_cards', 'scheduled_for')) {
$table->dropColumn('scheduled_for');
}
});
}
};

View File

@@ -0,0 +1,36 @@
<?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
{
if (! Schema::hasTable('dashboard_preferences')) {
return;
}
Schema::table('dashboard_preferences', function (Blueprint $table): void {
if (! Schema::hasColumn('dashboard_preferences', 'studio_preferences')) {
$table->json('studio_preferences')->nullable()->after('pinned_spaces');
}
});
}
public function down(): void
{
if (! Schema::hasTable('dashboard_preferences')) {
return;
}
Schema::table('dashboard_preferences', function (Blueprint $table): void {
if (Schema::hasColumn('dashboard_preferences', 'studio_preferences')) {
$table->dropColumn('studio_preferences');
}
});
}
};

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('content_moderation_findings', function (Blueprint $table) {
$table->id();
$table->string('content_type', 50)->index(); // artwork_comment, artwork_description
$table->unsignedBigInteger('content_id')->index(); // id of the source record
$table->unsignedBigInteger('artwork_id')->nullable()->index();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('status', 30)->default('pending')->index(); // pending, reviewed_safe, confirmed_spam, ignored, resolved
$table->string('severity', 20)->default('low')->index(); // low, medium, high, critical
$table->unsignedSmallInteger('score')->default(0);
$table->string('content_hash', 64); // sha256 of normalised content
$table->string('scanner_version', 20)->default('1.0');
$table->json('reasons_json')->nullable();
$table->json('matched_links_json')->nullable();
$table->json('matched_domains_json')->nullable();
$table->json('matched_keywords_json')->nullable();
$table->text('content_snapshot')->nullable();
$table->unsignedBigInteger('reviewed_by')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->string('action_taken', 50)->nullable();
$table->text('admin_notes')->nullable();
$table->timestamps();
// Prevent duplicate findings for unchanged content
$table->unique(['content_type', 'content_id', 'content_hash'], 'cmf_content_unique');
});
}
public function down(): void
{
Schema::dropIfExists('content_moderation_findings');
}
};

View File

@@ -0,0 +1,109 @@
<?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('content_moderation_findings', function (Blueprint $table): void {
$table->string('content_hash_normalized', 64)->nullable()->after('content_hash');
$table->string('group_key', 128)->nullable()->after('content_hash_normalized')->index();
$table->json('rule_hits_json')->nullable()->after('matched_keywords_json');
$table->json('domain_ids_json')->nullable()->after('rule_hits_json');
$table->integer('user_risk_score')->nullable()->after('domain_ids_json');
$table->boolean('is_auto_hidden')->default(false)->after('user_risk_score')->index();
$table->string('auto_action_taken', 50)->nullable()->after('is_auto_hidden');
$table->timestamp('auto_hidden_at')->nullable()->after('auto_action_taken');
$table->unsignedBigInteger('resolved_by')->nullable()->after('reviewed_by');
$table->timestamp('resolved_at')->nullable()->after('reviewed_at');
$table->unsignedBigInteger('restored_by')->nullable()->after('resolved_at');
$table->timestamp('restored_at')->nullable()->after('restored_by');
});
Schema::create('content_moderation_domains', function (Blueprint $table): void {
$table->id();
$table->string('domain', 255)->unique();
$table->string('status', 20)->default('neutral')->index();
$table->unsignedInteger('times_seen')->default(0);
$table->unsignedInteger('times_flagged')->default(0);
$table->unsignedInteger('times_confirmed_spam')->default(0);
$table->timestamp('first_seen_at')->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
Schema::create('content_moderation_rules', function (Blueprint $table): void {
$table->id();
$table->string('type', 40)->index();
$table->text('value');
$table->boolean('enabled')->default(true)->index();
$table->integer('weight')->nullable();
$table->text('notes')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
});
Schema::create('content_moderation_action_logs', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('finding_id')->nullable()->index();
$table->string('target_type', 50)->index();
$table->unsignedBigInteger('target_id')->nullable()->index();
$table->string('action_type', 50)->index();
$table->string('actor_type', 20)->default('system')->index();
$table->unsignedBigInteger('actor_id')->nullable()->index();
$table->string('old_status', 30)->nullable();
$table->string('new_status', 30)->nullable();
$table->string('old_visibility', 30)->nullable();
$table->string('new_visibility', 30)->nullable();
$table->text('notes')->nullable();
$table->json('meta_json')->nullable();
$table->timestamp('created_at')->useCurrent();
});
DB::table('content_moderation_findings')
->update([
'content_hash_normalized' => DB::raw('content_hash'),
'group_key' => DB::raw('content_hash'),
'resolved_at' => DB::raw("CASE WHEN status = 'resolved' THEN reviewed_at ELSE NULL END"),
'resolved_by' => DB::raw("CASE WHEN status = 'resolved' THEN reviewed_by ELSE NULL END"),
]);
Schema::table('content_moderation_findings', function (Blueprint $table): void {
$table->dropUnique('cmf_content_unique');
$table->unique(['content_type', 'content_id', 'content_hash', 'scanner_version'], 'cmf_content_scanner_unique');
});
}
public function down(): void
{
Schema::table('content_moderation_findings', function (Blueprint $table): void {
$table->dropUnique('cmf_content_scanner_unique');
$table->unique(['content_type', 'content_id', 'content_hash'], 'cmf_content_unique');
});
Schema::dropIfExists('content_moderation_action_logs');
Schema::dropIfExists('content_moderation_rules');
Schema::dropIfExists('content_moderation_domains');
Schema::table('content_moderation_findings', function (Blueprint $table): void {
$table->dropColumn([
'content_hash_normalized',
'group_key',
'rule_hits_json',
'domain_ids_json',
'user_risk_score',
'is_auto_hidden',
'auto_action_taken',
'auto_hidden_at',
'resolved_by',
'resolved_at',
'restored_by',
'restored_at',
]);
});
}
};

View File

@@ -0,0 +1,104 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration {
public function up(): void
{
if (! Schema::hasTable('artworks')) {
return;
}
if (DB::getDriverName() === 'sqlite') {
DB::statement('DROP INDEX IF EXISTS artworks_slug_unique');
DB::statement('DROP INDEX IF EXISTS artworks_slug');
DB::statement('CREATE INDEX IF NOT EXISTS artworks_slug_index ON artworks (slug)');
$this->normalizeArtworkSlugs();
return;
}
Schema::table('artworks', function (Blueprint $table) {
if ($this->indexExists('artworks', 'artworks_slug_unique')) {
$table->dropUnique('artworks_slug_unique');
}
if (! $this->indexExists('artworks', 'artworks_slug_index') && ! $this->indexExists('artworks', 'artworks_slug')) {
$table->index('slug');
}
});
$this->normalizeArtworkSlugs();
}
public function down(): void
{
if (! Schema::hasTable('artworks')) {
return;
}
if (DB::getDriverName() === 'sqlite') {
DB::statement('DROP INDEX IF EXISTS artworks_slug_index');
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS artworks_slug_unique ON artworks (slug)');
return;
}
Schema::table('artworks', function (Blueprint $table) {
if ($this->indexExists('artworks', 'artworks_slug_index')) {
$table->dropIndex('artworks_slug_index');
} elseif ($this->indexExists('artworks', 'artworks_slug')) {
$table->dropIndex('artworks_slug');
}
if (! $this->indexExists('artworks', 'artworks_slug_unique')) {
$table->unique('slug');
}
});
}
private function indexExists(string $tableName, string $indexName): bool
{
$driver = DB::getDriverName();
if ($driver === 'mysql') {
return DB::table('information_schema.statistics')
->where('table_schema', DB::getDatabaseName())
->where('table_name', $tableName)
->where('index_name', $indexName)
->exists();
}
if ($driver === 'pgsql') {
return DB::table('pg_indexes')
->where('schemaname', 'public')
->where('tablename', $tableName)
->where('indexname', $indexName)
->exists();
}
return false;
}
private function normalizeArtworkSlugs(): void
{
DB::table('artworks')
->select(['id', 'title'])
->orderBy('id')
->chunkById(500, function (Collection $artworks): void {
foreach ($artworks as $artwork) {
$slug = Str::limit(Str::slug((string) ($artwork->title ?? '')) ?: 'artwork', 160, '');
DB::table('artworks')
->where('id', $artwork->id)
->update(['slug' => $slug]);
}
});
}
};

View File

@@ -0,0 +1,130 @@
<?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('content_moderation_findings', function (Blueprint $table): void {
$table->string('content_target_type', 60)->nullable()->after('content_id')->index();
$table->unsignedBigInteger('content_target_id')->nullable()->after('content_target_type')->index();
$table->string('campaign_key', 80)->nullable()->after('group_key')->index();
$table->unsignedInteger('cluster_score')->nullable()->after('campaign_key');
$table->string('cluster_reason', 80)->nullable()->after('cluster_score');
$table->unsignedInteger('priority_score')->default(0)->after('cluster_reason')->index();
$table->string('ai_provider', 60)->nullable()->after('priority_score');
$table->string('ai_label', 60)->nullable()->after('ai_provider')->index();
$table->string('ai_suggested_action', 60)->nullable()->after('ai_label');
$table->unsignedTinyInteger('ai_confidence')->nullable()->after('ai_suggested_action');
$table->text('ai_explanation')->nullable()->after('ai_confidence');
$table->unsignedInteger('false_positive_count')->default(0)->after('ai_explanation');
$table->boolean('is_false_positive')->default(false)->after('false_positive_count')->index();
$table->string('policy_name', 80)->nullable()->after('is_false_positive')->index();
$table->string('review_bucket', 40)->nullable()->after('policy_name')->index();
$table->string('escalation_status', 40)->default('none')->after('review_bucket')->index();
$table->json('score_breakdown_json')->nullable()->after('rule_hits_json');
});
Schema::table('content_moderation_domains', function (Blueprint $table): void {
$table->unsignedInteger('linked_users_count')->default(0)->after('times_confirmed_spam');
$table->unsignedInteger('linked_findings_count')->default(0)->after('linked_users_count');
$table->unsignedInteger('linked_clusters_count')->default(0)->after('linked_findings_count');
$table->json('top_keywords_json')->nullable()->after('last_seen_at');
$table->json('top_content_types_json')->nullable()->after('top_keywords_json');
$table->unsignedInteger('false_positive_count')->default(0)->after('top_content_types_json');
});
Schema::create('content_moderation_clusters', function (Blueprint $table): void {
$table->id();
$table->string('campaign_key', 80)->unique();
$table->string('cluster_reason', 80)->nullable();
$table->string('review_bucket', 40)->nullable()->index();
$table->string('escalation_status', 40)->default('none')->index();
$table->unsignedInteger('cluster_score')->default(0)->index();
$table->unsignedInteger('findings_count')->default(0);
$table->unsignedInteger('unique_users_count')->default(0);
$table->unsignedInteger('unique_domains_count')->default(0);
$table->timestamp('latest_finding_at')->nullable()->index();
$table->json('summary_json')->nullable();
$table->timestamps();
});
Schema::create('content_moderation_ai_suggestions', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('finding_id')->index();
$table->string('provider', 60)->index();
$table->string('suggested_label', 60)->nullable()->index();
$table->string('suggested_action', 60)->nullable();
$table->unsignedTinyInteger('confidence')->nullable();
$table->text('explanation')->nullable();
$table->json('campaign_tags_json')->nullable();
$table->json('raw_response_json')->nullable();
$table->timestamp('created_at')->useCurrent();
});
Schema::create('content_moderation_feedback', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('finding_id')->index();
$table->string('feedback_type', 60)->index();
$table->unsignedBigInteger('actor_id')->nullable()->index();
$table->text('notes')->nullable();
$table->json('meta_json')->nullable();
$table->timestamp('created_at')->useCurrent();
});
DB::table('content_moderation_findings')->update([
'content_target_type' => DB::raw("CASE WHEN content_type = 'artwork_comment' THEN 'artwork_comment' ELSE 'artwork' END"),
'content_target_id' => DB::raw("CASE WHEN content_type = 'artwork_comment' THEN content_id ELSE COALESCE(artwork_id, content_id) END"),
'campaign_key' => DB::raw('group_key'),
'cluster_score' => DB::raw('score'),
'cluster_reason' => 'legacy_group_key',
'priority_score' => DB::raw('score'),
'policy_name' => 'default',
'review_bucket' => DB::raw("CASE WHEN score >= 90 THEN 'urgent' WHEN score >= 60 THEN 'high' WHEN score >= 30 THEN 'priority' ELSE 'standard' END"),
'escalation_status' => DB::raw("CASE WHEN score >= 90 THEN 'urgent' WHEN score >= 60 THEN 'escalated' WHEN score >= 30 THEN 'review_required' ELSE 'none' END"),
]);
}
public function down(): void
{
Schema::dropIfExists('content_moderation_feedback');
Schema::dropIfExists('content_moderation_ai_suggestions');
Schema::dropIfExists('content_moderation_clusters');
Schema::table('content_moderation_domains', function (Blueprint $table): void {
$table->dropColumn([
'linked_users_count',
'linked_findings_count',
'linked_clusters_count',
'top_keywords_json',
'top_content_types_json',
'false_positive_count',
]);
});
Schema::table('content_moderation_findings', function (Blueprint $table): void {
$table->dropColumn([
'content_target_type',
'content_target_id',
'campaign_key',
'cluster_score',
'cluster_reason',
'priority_score',
'ai_provider',
'ai_label',
'ai_suggested_action',
'ai_confidence',
'ai_explanation',
'false_positive_count',
'is_false_positive',
'policy_name',
'review_bucket',
'escalation_status',
'score_breakdown_json',
]);
});
}
};