Commit workspace changes

This commit is contained in:
2026-04-05 19:42:33 +02:00
parent 148a3bbe43
commit 08ad757bcb
312 changed files with 35149 additions and 399 deletions

View File

@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use App\Models\Group;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class GroupFactory extends Factory
{
protected $model = Group::class;
public function definition(): array
{
$name = $this->faker->unique()->company();
return [
'owner_user_id' => User::factory(),
'name' => $name,
'slug' => Str::slug($name),
'headline' => $this->faker->sentence(6),
'bio' => $this->faker->paragraph(),
'visibility' => Group::VISIBILITY_PUBLIC,
'status' => Group::LIFECYCLE_ACTIVE,
'membership_policy' => Group::MEMBERSHIP_INVITE_ONLY,
'website_url' => $this->faker->optional()->url(),
'links_json' => [],
'avatar_path' => null,
'banner_path' => null,
'artworks_count' => 0,
'collections_count' => 0,
'followers_count' => 0,
'last_activity_at' => now(),
];
}
public function private(): self
{
return $this->state(fn (): array => [
'visibility' => Group::VISIBILITY_PRIVATE,
]);
}
}

View File

@@ -0,0 +1,96 @@
<?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('groups', function (Blueprint $table): void {
$table->id();
$table->foreignId('owner_user_id')
->constrained('users')
->cascadeOnDelete();
$table->string('name', 80);
$table->string('slug', 90)->unique();
$table->string('headline', 160)->nullable();
$table->text('bio')->nullable();
$table->enum('visibility', ['public', 'private'])->default('public');
$table->string('website_url', 2048)->nullable();
$table->json('links_json')->nullable();
$table->string('avatar_path')->nullable();
$table->string('banner_path')->nullable();
$table->unsignedInteger('artworks_count')->default(0);
$table->unsignedInteger('collections_count')->default(0);
$table->unsignedInteger('followers_count')->default(0);
$table->timestamp('last_activity_at')->nullable();
$table->timestamps();
$table->index(['visibility', 'followers_count']);
$table->index(['owner_user_id', 'created_at']);
});
Schema::create('group_members', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')
->constrained('groups')
->cascadeOnDelete();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete();
$table->foreignId('invited_by_user_id')
->nullable()
->constrained('users')
->nullOnDelete();
$table->enum('role', ['owner', 'admin', 'editor', 'member'])->default('member');
$table->enum('status', ['pending', 'active', 'revoked'])->default('pending');
$table->text('note')->nullable();
$table->timestamp('invited_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('accepted_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->unique(['group_id', 'user_id'], 'group_members_group_user_unique');
$table->index(['group_id', 'status', 'role'], 'group_members_status_role_idx');
});
Schema::create('group_follows', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')
->constrained('groups')
->cascadeOnDelete();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete();
$table->timestamps();
$table->unique(['group_id', 'user_id'], 'group_follows_group_user_unique');
});
Schema::create('artwork_contributors', function (Blueprint $table): void {
$table->id();
$table->foreignId('artwork_id')
->constrained('artworks')
->cascadeOnDelete();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete();
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->unique(['artwork_id', 'user_id'], 'artwork_contributors_artwork_user_unique');
$table->index(['artwork_id', 'sort_order'], 'artwork_contributors_sort_idx');
});
}
public function down(): void
{
Schema::dropIfExists('artwork_contributors');
Schema::dropIfExists('group_follows');
Schema::dropIfExists('group_members');
Schema::dropIfExists('groups');
}
};

View File

@@ -0,0 +1,56 @@
<?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->foreignId('group_id')
->nullable()
->after('user_id')
->constrained('groups')
->nullOnDelete();
$table->foreignId('uploaded_by_user_id')
->nullable()
->after('group_id')
->constrained('users')
->nullOnDelete();
$table->foreignId('primary_author_user_id')
->nullable()
->after('uploaded_by_user_id')
->constrained('users')
->nullOnDelete();
$table->index(['group_id', 'published_at'], 'artworks_group_published_idx');
});
Schema::table('collections', function (Blueprint $table): void {
$table->foreignId('group_id')
->nullable()
->after('user_id')
->constrained('groups')
->nullOnDelete();
$table->index(['group_id', 'visibility'], 'collections_group_visibility_idx');
});
}
public function down(): void
{
Schema::table('collections', function (Blueprint $table): void {
$table->dropIndex('collections_group_visibility_idx');
$table->dropConstrainedForeignId('group_id');
});
Schema::table('artworks', function (Blueprint $table): void {
$table->dropIndex('artworks_group_published_idx');
$table->dropConstrainedForeignId('primary_author_user_id');
$table->dropConstrainedForeignId('uploaded_by_user_id');
$table->dropConstrainedForeignId('group_id');
});
}
};

View File

@@ -0,0 +1,83 @@
<?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('groups', function (Blueprint $table): void {
if (! Schema::hasColumn('groups', 'status')) {
$table->string('status', 24)->default('active')->after('visibility');
}
if (! Schema::hasColumn('groups', 'membership_policy')) {
$table->string('membership_policy', 32)->default('invite_only')->after('status');
}
if (! Schema::hasColumn('groups', 'type')) {
$table->string('type', 80)->nullable()->after('bio');
}
if (! Schema::hasColumn('groups', 'founded_at')) {
$table->timestamp('founded_at')->nullable()->after('owner_user_id');
}
if (! Schema::hasColumn('groups', 'featured_artwork_id')) {
$table->foreignId('featured_artwork_id')->nullable()->after('owner_user_id')->constrained('artworks')->nullOnDelete();
}
if (! Schema::hasColumn('groups', 'is_verified')) {
$table->boolean('is_verified')->default(false)->after('featured_artwork_id');
}
if (! Schema::hasColumn('groups', 'deleted_at')) {
$table->softDeletes();
}
});
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement("ALTER TABLE `groups` MODIFY `visibility` ENUM('public','private','unlisted') NOT NULL DEFAULT 'public'");
}
}
public function down(): void
{
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement("ALTER TABLE `groups` MODIFY `visibility` ENUM('public','private') NOT NULL DEFAULT 'public'");
}
Schema::table('groups', function (Blueprint $table): void {
if (Schema::hasColumn('groups', 'deleted_at')) {
$table->dropSoftDeletes();
}
if (Schema::hasColumn('groups', 'is_verified')) {
$table->dropColumn('is_verified');
}
if (Schema::hasColumn('groups', 'featured_artwork_id')) {
$table->dropConstrainedForeignId('featured_artwork_id');
}
if (Schema::hasColumn('groups', 'founded_at')) {
$table->dropColumn('founded_at');
}
if (Schema::hasColumn('groups', 'type')) {
$table->dropColumn('type');
}
if (Schema::hasColumn('groups', 'membership_policy')) {
$table->dropColumn('membership_policy');
}
if (Schema::hasColumn('groups', 'status')) {
$table->dropColumn('status');
}
});
}
};

View File

@@ -0,0 +1,89 @@
<?php
use App\Models\GroupInvitation;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::create('group_invitations', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')
->constrained('groups')
->cascadeOnDelete();
$table->foreignId('invited_user_id')
->constrained('users')
->cascadeOnDelete();
$table->foreignId('invited_by_user_id')
->nullable()
->constrained('users')
->nullOnDelete();
$table->foreignId('source_group_member_id')
->nullable()
->constrained('group_members')
->nullOnDelete();
$table->enum('role', ['admin', 'editor', 'member'])->default('member');
$table->enum('status', [
GroupInvitation::STATUS_PENDING,
GroupInvitation::STATUS_ACCEPTED,
GroupInvitation::STATUS_DECLINED,
GroupInvitation::STATUS_REVOKED,
GroupInvitation::STATUS_EXPIRED,
])->default(GroupInvitation::STATUS_PENDING);
$table->string('token', 80)->unique();
$table->text('note')->nullable();
$table->timestamp('invited_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('responded_at')->nullable();
$table->timestamp('accepted_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->index(['group_id', 'status', 'role'], 'group_invitations_status_role_idx');
$table->index(['invited_user_id', 'status'], 'group_invitations_user_status_idx');
});
if (! Schema::hasTable('group_members')) {
return;
}
DB::table('group_members')
->whereIn('status', ['pending', 'revoked'])
->orderBy('id')
->get()
->each(function (object $member): void {
$status = match ((string) $member->status) {
'pending' => GroupInvitation::STATUS_PENDING,
default => GroupInvitation::STATUS_REVOKED,
};
DB::table('group_invitations')->insert([
'group_id' => (int) $member->group_id,
'invited_user_id' => (int) $member->user_id,
'invited_by_user_id' => $member->invited_by_user_id ? (int) $member->invited_by_user_id : null,
'source_group_member_id' => (int) $member->id,
'role' => (string) $member->role,
'status' => $status,
'token' => Str::random(64),
'note' => $member->note,
'invited_at' => $member->invited_at,
'expires_at' => $member->expires_at,
'responded_at' => $member->accepted_at ?? $member->revoked_at,
'accepted_at' => null,
'revoked_at' => $member->revoked_at,
'created_at' => $member->created_at ?? now(),
'updated_at' => $member->updated_at ?? now(),
]);
});
}
public function down(): void
{
Schema::dropIfExists('group_invitations');
}
};

View File

@@ -0,0 +1,45 @@
<?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('published_as_type', 16)
->nullable()
->after('primary_author_user_id');
$table->unsignedBigInteger('published_as_id')
->nullable()
->after('published_as_type');
$table->index(['published_as_type', 'published_as_id'], 'artworks_published_as_idx');
});
DB::table('artworks')
->whereNotNull('group_id')
->update([
'published_as_type' => 'group',
'published_as_id' => DB::raw('group_id'),
]);
DB::table('artworks')
->whereNull('published_as_id')
->update([
'published_as_type' => 'user',
'published_as_id' => DB::raw('user_id'),
]);
}
public function down(): void
{
Schema::table('artworks', function (Blueprint $table): void {
$table->dropIndex('artworks_published_as_idx');
$table->dropColumn(['published_as_type', 'published_as_id']);
});
}
};

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('artwork_contributors', function (Blueprint $table): void {
$table->string('credit_role', 80)->nullable()->after('user_id');
$table->boolean('is_primary')->default(false)->after('credit_role');
});
}
public function down(): void
{
Schema::table('artwork_contributors', function (Blueprint $table): void {
$table->dropColumn(['credit_role', 'is_primary']);
});
}
};

View File

@@ -0,0 +1,163 @@
<?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('group_join_requests', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')
->constrained('groups')
->cascadeOnDelete();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete();
$table->text('message')->nullable();
$table->string('portfolio_url', 2048)->nullable();
$table->string('desired_role', 32)->nullable();
$table->json('skills_json')->nullable();
$table->string('status', 24)->default('pending');
$table->foreignId('reviewed_by_user_id')
->nullable()
->constrained('users')
->nullOnDelete();
$table->text('review_notes')->nullable();
$table->timestamp('reviewed_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->index(['group_id', 'status', 'created_at'], 'group_join_requests_group_status_idx');
$table->index(['group_id', 'user_id', 'status'], 'group_join_requests_group_user_status_idx');
});
Schema::create('group_posts', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')
->constrained('groups')
->cascadeOnDelete();
$table->foreignId('author_user_id')
->constrained('users')
->cascadeOnDelete();
$table->string('type', 32)->default('announcement');
$table->string('title', 180);
$table->string('slug', 190)->unique();
$table->string('excerpt', 320)->nullable();
$table->longText('content')->nullable();
$table->string('cover_path', 2048)->nullable();
$table->string('status', 24)->default('draft');
$table->boolean('is_pinned')->default(false);
$table->timestamp('published_at')->nullable();
$table->softDeletes();
$table->timestamps();
$table->index(['group_id', 'status', 'published_at'], 'group_posts_group_status_published_idx');
$table->index(['group_id', 'is_pinned', 'published_at'], 'group_posts_group_pinned_idx');
});
Schema::create('group_recruitment_profiles', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')
->constrained('groups')
->cascadeOnDelete();
$table->boolean('is_recruiting')->default(false);
$table->string('headline', 180)->nullable();
$table->text('description')->nullable();
$table->json('roles_json')->nullable();
$table->json('skills_json')->nullable();
$table->string('contact_mode', 32)->nullable();
$table->string('visibility', 24)->default('public');
$table->timestamps();
$table->unique('group_id', 'group_recruitment_profiles_group_unique');
});
Schema::create('group_histories', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')
->constrained('groups')
->cascadeOnDelete();
$table->foreignId('actor_user_id')
->nullable()
->constrained('users')
->nullOnDelete();
$table->string('action_type', 64);
$table->string('target_type', 64)->nullable();
$table->unsignedBigInteger('target_id')->nullable();
$table->string('summary', 255)->nullable();
$table->json('before_json')->nullable();
$table->json('after_json')->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['group_id', 'action_type', 'created_at'], 'group_histories_group_action_idx');
$table->index(['group_id', 'target_type', 'target_id'], 'group_histories_target_idx');
});
Schema::table('group_members', function (Blueprint $table): void {
if (! Schema::hasColumn('group_members', 'permission_overrides_json')) {
$table->json('permission_overrides_json')->nullable()->after('note');
}
});
Schema::table('artworks', function (Blueprint $table): void {
if (! Schema::hasColumn('artworks', 'group_review_status')) {
$table->string('group_review_status', 24)->default('none')->after('artwork_status');
}
if (! Schema::hasColumn('artworks', 'group_review_submitted_at')) {
$table->timestamp('group_review_submitted_at')->nullable()->after('group_review_status');
}
if (! Schema::hasColumn('artworks', 'group_reviewed_by_user_id')) {
$table->foreignId('group_reviewed_by_user_id')
->nullable()
->after('group_review_submitted_at')
->constrained('users')
->nullOnDelete();
}
if (! Schema::hasColumn('artworks', 'group_reviewed_at')) {
$table->timestamp('group_reviewed_at')->nullable()->after('group_reviewed_by_user_id');
}
if (! Schema::hasColumn('artworks', 'group_review_notes')) {
$table->text('group_review_notes')->nullable()->after('group_reviewed_at');
}
$table->index(['group_id', 'group_review_status', 'group_review_submitted_at'], 'artworks_group_review_queue_idx');
});
}
public function down(): void
{
Schema::table('artworks', function (Blueprint $table): void {
if (Schema::hasColumn('artworks', 'group_reviewed_by_user_id')) {
$table->dropConstrainedForeignId('group_reviewed_by_user_id');
}
if (Schema::hasColumn('artworks', 'group_review_status')) {
$table->dropIndex('artworks_group_review_queue_idx');
$table->dropColumn([
'group_review_status',
'group_review_submitted_at',
'group_reviewed_at',
'group_review_notes',
]);
}
});
Schema::table('group_members', function (Blueprint $table): void {
if (Schema::hasColumn('group_members', 'permission_overrides_json')) {
$table->dropColumn('permission_overrides_json');
}
});
Schema::dropIfExists('group_histories');
Schema::dropIfExists('group_recruitment_profiles');
Schema::dropIfExists('group_posts');
Schema::dropIfExists('group_join_requests');
}
};

View File

@@ -0,0 +1,180 @@
<?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('group_projects', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->string('title', 180);
$table->string('slug', 190)->unique();
$table->string('summary', 320)->nullable();
$table->longText('description')->nullable();
$table->string('cover_path', 2048)->nullable();
$table->string('status', 24)->default('planned');
$table->string('visibility', 24)->default('public');
$table->date('start_date')->nullable();
$table->date('target_date')->nullable();
$table->timestamp('released_at')->nullable();
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('lead_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('linked_collection_id')->nullable()->constrained('collections')->nullOnDelete();
$table->foreignId('linked_featured_artwork_id')->nullable()->constrained('artworks')->nullOnDelete();
$table->foreignId('pinned_post_id')->nullable()->constrained('group_posts')->nullOnDelete();
$table->softDeletes();
$table->timestamps();
$table->index(['group_id', 'status', 'visibility'], 'group_projects_group_status_visibility_idx');
$table->index(['group_id', 'released_at'], 'group_projects_group_released_idx');
});
Schema::create('group_project_artworks', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_project_id')->constrained('group_projects')->cascadeOnDelete();
$table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete();
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->unique(['group_project_id', 'artwork_id'], 'group_project_artworks_unique');
$table->index(['group_project_id', 'sort_order'], 'group_project_artworks_sort_idx');
});
Schema::create('group_project_members', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_project_id')->constrained('group_projects')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('role_label', 80)->nullable();
$table->boolean('is_lead')->default(false);
$table->timestamps();
$table->unique(['group_project_id', 'user_id'], 'group_project_members_unique');
$table->index(['group_project_id', 'is_lead'], 'group_project_members_lead_idx');
});
Schema::create('group_challenges', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->string('title', 180);
$table->string('slug', 190)->unique();
$table->string('summary', 320)->nullable();
$table->longText('description')->nullable();
$table->string('cover_path', 2048)->nullable();
$table->string('visibility', 24)->default('public');
$table->string('participation_scope', 24)->default('group_only');
$table->string('status', 24)->default('draft');
$table->timestamp('start_at')->nullable();
$table->timestamp('end_at')->nullable();
$table->text('rules_text')->nullable();
$table->text('submission_instructions')->nullable();
$table->string('judging_mode', 32)->nullable();
$table->foreignId('linked_collection_id')->nullable()->constrained('collections')->nullOnDelete();
$table->foreignId('linked_project_id')->nullable()->constrained('group_projects')->nullOnDelete();
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('featured_artwork_id')->nullable()->constrained('artworks')->nullOnDelete();
$table->softDeletes();
$table->timestamps();
$table->index(['group_id', 'status', 'visibility'], 'group_challenges_group_status_visibility_idx');
$table->index(['group_id', 'start_at', 'end_at'], 'group_challenges_group_window_idx');
});
Schema::create('group_challenge_artworks', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_challenge_id')->constrained('group_challenges')->cascadeOnDelete();
$table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete();
$table->foreignId('submitted_by_user_id')->constrained('users')->cascadeOnDelete();
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->unique(['group_challenge_id', 'artwork_id'], 'group_challenge_artworks_unique');
$table->index(['group_challenge_id', 'sort_order'], 'group_challenge_artworks_sort_idx');
});
Schema::create('group_events', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->string('title', 180);
$table->string('slug', 190)->unique();
$table->string('summary', 320)->nullable();
$table->longText('description')->nullable();
$table->string('event_type', 32)->default('launch');
$table->string('visibility', 24)->default('public');
$table->timestamp('start_at')->nullable();
$table->timestamp('end_at')->nullable();
$table->string('timezone', 80)->default('UTC');
$table->string('cover_path', 2048)->nullable();
$table->string('location', 180)->nullable();
$table->string('external_url', 2048)->nullable();
$table->foreignId('linked_project_id')->nullable()->constrained('group_projects')->nullOnDelete();
$table->foreignId('linked_collection_id')->nullable()->constrained('collections')->nullOnDelete();
$table->foreignId('linked_challenge_id')->nullable()->constrained('group_challenges')->nullOnDelete();
$table->string('status', 24)->default('draft');
$table->boolean('is_featured')->default(false);
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('published_at')->nullable();
$table->softDeletes();
$table->timestamps();
$table->index(['group_id', 'status', 'visibility'], 'group_events_group_status_visibility_idx');
$table->index(['group_id', 'start_at'], 'group_events_group_start_idx');
});
Schema::create('group_assets', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->string('title', 180);
$table->text('description')->nullable();
$table->string('category', 32)->default('misc');
$table->string('file_path', 2048);
$table->string('preview_path', 2048)->nullable();
$table->string('visibility', 24)->default('members_only');
$table->string('status', 24)->default('active');
$table->foreignId('linked_project_id')->nullable()->constrained('group_projects')->nullOnDelete();
$table->foreignId('uploaded_by_user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('approved_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->boolean('is_featured')->default(false);
$table->json('file_meta_json')->nullable();
$table->softDeletes();
$table->timestamps();
$table->index(['group_id', 'visibility', 'status'], 'group_assets_group_visibility_status_idx');
$table->index(['group_id', 'category'], 'group_assets_group_category_idx');
});
Schema::create('group_activity_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->string('type', 48);
$table->string('visibility', 24)->default('public');
$table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('subject_type', 64);
$table->unsignedBigInteger('subject_id')->nullable();
$table->string('headline', 255);
$table->string('summary', 500)->nullable();
$table->boolean('is_pinned')->default(false);
$table->timestamp('occurred_at');
$table->timestamps();
$table->index(['group_id', 'visibility', 'occurred_at'], 'group_activity_items_group_visibility_occurred_idx');
$table->index(['group_id', 'type', 'occurred_at'], 'group_activity_items_group_type_occurred_idx');
$table->index(['group_id', 'subject_type', 'subject_id'], 'group_activity_items_subject_idx');
});
}
public function down(): void
{
Schema::dropIfExists('group_activity_items');
Schema::dropIfExists('group_assets');
Schema::dropIfExists('group_events');
Schema::dropIfExists('group_challenge_artworks');
Schema::dropIfExists('group_challenges');
Schema::dropIfExists('group_project_members');
Schema::dropIfExists('group_project_artworks');
Schema::dropIfExists('group_projects');
}
};

View File

@@ -0,0 +1,164 @@
<?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('group_releases', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->string('title', 180);
$table->string('slug', 190)->unique();
$table->string('summary', 320)->nullable();
$table->longText('description')->nullable();
$table->string('cover_path', 2048)->nullable();
$table->string('status', 32)->default('planned');
$table->string('current_stage', 32)->default('concept');
$table->string('visibility', 24)->default('public');
$table->timestamp('planned_release_at')->nullable();
$table->timestamp('released_at')->nullable();
$table->foreignId('lead_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('linked_project_id')->nullable()->constrained('group_projects')->nullOnDelete();
$table->foreignId('linked_collection_id')->nullable()->constrained('collections')->nullOnDelete();
$table->foreignId('featured_artwork_id')->nullable()->constrained('artworks')->nullOnDelete();
$table->longText('release_notes')->nullable();
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('published_at')->nullable();
$table->boolean('is_featured')->default(false);
$table->softDeletes();
$table->timestamps();
$table->index(['group_id', 'status', 'visibility'], 'group_releases_group_status_visibility_idx');
$table->index(['group_id', 'current_stage'], 'group_releases_group_stage_idx');
$table->index(['group_id', 'planned_release_at'], 'group_releases_group_planned_idx');
$table->index(['group_id', 'released_at'], 'group_releases_group_released_idx');
});
Schema::create('group_release_artworks', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_release_id')->constrained('group_releases')->cascadeOnDelete();
$table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete();
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->unique(['group_release_id', 'artwork_id'], 'group_release_artworks_unique');
$table->index(['group_release_id', 'sort_order'], 'group_release_artworks_sort_idx');
});
Schema::create('group_release_contributors', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_release_id')->constrained('group_releases')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('role_label', 80)->nullable();
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->unique(['group_release_id', 'user_id'], 'group_release_contributors_unique');
$table->index(['group_release_id', 'sort_order'], 'group_release_contributors_sort_idx');
});
Schema::create('group_project_milestones', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_project_id')->constrained('group_projects')->cascadeOnDelete();
$table->string('title', 180);
$table->string('summary', 320)->nullable();
$table->string('status', 24)->default('pending');
$table->date('due_date')->nullable();
$table->foreignId('owner_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->unsignedInteger('sort_order')->default(0);
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['group_project_id', 'status'], 'group_project_milestones_project_status_idx');
$table->index(['group_project_id', 'due_date'], 'group_project_milestones_project_due_idx');
});
Schema::create('group_release_milestones', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_release_id')->constrained('group_releases')->cascadeOnDelete();
$table->string('title', 180);
$table->string('summary', 320)->nullable();
$table->string('status', 24)->default('pending');
$table->date('due_date')->nullable();
$table->foreignId('owner_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->unsignedInteger('sort_order')->default(0);
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['group_release_id', 'status'], 'group_release_milestones_release_status_idx');
$table->index(['group_release_id', 'due_date'], 'group_release_milestones_release_due_idx');
});
Schema::create('group_contributor_stats', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->unsignedInteger('credited_artworks_count')->default(0);
$table->unsignedInteger('release_count')->default(0);
$table->unsignedInteger('project_count')->default(0);
$table->unsignedInteger('review_actions_count')->default(0);
$table->unsignedInteger('approved_submissions_count')->default(0);
$table->json('reputation_meta_json')->nullable();
$table->timestamps();
$table->unique(['group_id', 'user_id'], 'group_contributor_stats_group_user_unique');
$table->index(['group_id', 'release_count'], 'group_contributor_stats_group_release_idx');
$table->index(['group_id', 'credited_artworks_count'], 'group_contributor_stats_group_artworks_idx');
});
Schema::create('group_badges', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->string('badge_key', 80);
$table->timestamp('awarded_at');
$table->json('meta_json')->nullable();
$table->timestamps();
$table->unique(['group_id', 'badge_key'], 'group_badges_group_badge_unique');
});
Schema::create('group_member_badges', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('badge_key', 80);
$table->timestamp('awarded_at');
$table->json('meta_json')->nullable();
$table->timestamps();
$table->unique(['group_id', 'user_id', 'badge_key'], 'group_member_badges_group_user_badge_unique');
$table->index(['group_id', 'user_id'], 'group_member_badges_group_user_idx');
});
Schema::create('group_discovery_metrics', function (Blueprint $table): void {
$table->id();
$table->foreignId('group_id')->constrained('groups')->cascadeOnDelete();
$table->decimal('freshness_score', 8, 2)->default(0);
$table->decimal('activity_score', 8, 2)->default(0);
$table->decimal('release_score', 8, 2)->default(0);
$table->decimal('trust_score', 8, 2)->default(0);
$table->decimal('collaboration_score', 8, 2)->default(0);
$table->timestamp('last_calculated_at')->nullable();
$table->timestamps();
$table->unique('group_id', 'group_discovery_metrics_group_unique');
});
}
public function down(): void
{
Schema::dropIfExists('group_discovery_metrics');
Schema::dropIfExists('group_member_badges');
Schema::dropIfExists('group_badges');
Schema::dropIfExists('group_contributor_stats');
Schema::dropIfExists('group_release_milestones');
Schema::dropIfExists('group_project_milestones');
Schema::dropIfExists('group_release_contributors');
Schema::dropIfExists('group_release_artworks');
Schema::dropIfExists('group_releases');
}
};

View File

@@ -0,0 +1,120 @@
<?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
{
if (Schema::hasTable('news_articles')) {
$this->addNewsArticleColumns();
$this->backfillNewsArticleColumns();
}
if (! Schema::hasTable('news_article_relations')) {
Schema::create('news_article_relations', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('article_id')->index();
$table->string('entity_type', 40)->index();
$table->unsignedBigInteger('entity_id')->index();
$table->string('context_label', 120)->nullable();
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->foreign('article_id')->references('id')->on('news_articles')->onDelete('cascade');
$table->unique(['article_id', 'entity_type', 'entity_id'], 'news_article_relations_unique');
});
}
}
public function down(): void
{
Schema::dropIfExists('news_article_relations');
if (! Schema::hasTable('news_articles')) {
return;
}
$columns = [
'editorial_status',
'type',
'is_pinned',
'canonical_url',
];
$existing = array_values(array_filter($columns, static fn (string $column): bool => Schema::hasColumn('news_articles', $column)));
if ($existing !== []) {
Schema::table('news_articles', function (Blueprint $table) use ($existing): void {
$table->dropColumn($existing);
});
}
}
private function addNewsArticleColumns(): void
{
$needsTableChange = false;
foreach (['editorial_status', 'type', 'is_pinned', 'canonical_url'] as $column) {
if (! Schema::hasColumn('news_articles', $column)) {
$needsTableChange = true;
break;
}
}
if (! $needsTableChange) {
return;
}
Schema::table('news_articles', function (Blueprint $table): void {
if (! Schema::hasColumn('news_articles', 'editorial_status')) {
$table->string('editorial_status', 30)->default('draft')->after('status')->index();
}
if (! Schema::hasColumn('news_articles', 'type')) {
$table->string('type', 40)->default('announcement')->after('cover_image')->index();
}
if (! Schema::hasColumn('news_articles', 'is_pinned')) {
$table->boolean('is_pinned')->default(false)->after('is_featured')->index();
}
if (! Schema::hasColumn('news_articles', 'canonical_url')) {
$table->string('canonical_url')->nullable()->after('meta_description');
}
});
}
private function backfillNewsArticleColumns(): void
{
DB::table('news_articles')
->select(['id', 'status', 'editorial_status', 'type'])
->orderBy('id')
->chunkById(200, function ($rows): void {
foreach ($rows as $row) {
$payload = [];
if (property_exists($row, 'editorial_status') && ($row->editorial_status === null || $row->editorial_status === '')) {
$payload['editorial_status'] = match ((string) $row->status) {
'published' => 'published',
'scheduled' => 'scheduled',
default => 'draft',
};
}
if (property_exists($row, 'type') && ($row->type === null || $row->type === '')) {
$payload['type'] = 'announcement';
}
if ($payload !== []) {
DB::table('news_articles')->where('id', $row->id)->update($payload);
}
}
});
}
};

View File

@@ -5,6 +5,7 @@ namespace Database\Seeders;
use Database\Seeders\NovaCardCategorySeeder;
use Database\Seeders\NovaCardDemoSeeder;
use Database\Seeders\NovaCardTemplateSeeder;
use Database\Seeders\NewsLaunchSeeder;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -24,6 +25,7 @@ class DatabaseSeeder extends Seeder
NovaCardCategorySeeder::class,
NovaCardTemplateSeeder::class,
NovaCardDemoSeeder::class,
NewsLaunchSeeder::class,
]);
User::factory()->create([

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag;
final class NewsLaunchSeeder extends Seeder
{
public function run(): void
{
$author = User::query()->firstWhere('email', 'newsroom@skinbase.local');
if (! $author) {
$author = new User();
$author->forceFill([
'name' => 'Skinbase Editorial',
'username' => 'skinbaseeditorial',
'email' => 'newsroom@skinbase.local',
'password' => Hash::make('password'),
'role' => 'moderator',
])->save();
$author = User::query()->firstWhere('email', 'newsroom@skinbase.local');
}
if (! $author) {
return;
}
$categories = [
'platform' => $this->upsertCategory('Platform', 'platform', 'Product updates, roadmap notes, and feature launches.'),
'groups' => $this->upsertCategory('Groups', 'groups', 'Stories about collaborative publishing, teams, and shared identity.'),
'tutorials' => $this->upsertCategory('Tutorials', 'tutorials', 'Practical guides for getting more from Nova.'),
'spotlight' => $this->upsertCategory('Spotlight', 'spotlight', 'Featured creators, groups, and standout work.'),
'releases' => $this->upsertCategory('Releases', 'releases', 'Coverage for launches, drops, and project milestones.'),
];
$tags = [
'nova' => $this->upsertTag('Nova', 'nova'),
'tutorial' => $this->upsertTag('Tutorial', 'tutorial'),
'groups' => $this->upsertTag('Groups', 'groups'),
'release' => $this->upsertTag('Release', 'release'),
'spotlight' => $this->upsertTag('Spotlight', 'spotlight'),
'platform-update' => $this->upsertTag('Platform Update', 'platform-update'),
];
$articles = [
[
'slug' => 'welcome-to-skinbase-nova',
'title' => 'Welcome to Skinbase Nova',
'type' => NewsArticle::TYPE_PLATFORM_UPDATE,
'category' => $categories['platform'],
'excerpt' => 'A first look at the refreshed Skinbase experience and the editorial direction behind Nova.',
'content' => "# Welcome to Skinbase Nova\n\nSkinbase Nova brings publishing, discovery, Groups, and editorial storytelling into a single platform experience.\n\n## What is new\n\n- a dedicated newsroom\n- stronger creator identity surfaces\n- deeper internal linking across Groups, releases, and profiles\n- cleaner editorial publishing tools inside Studio\n\nNova is designed to feel active, curated, and connected to the people making the work.",
'tags' => [$tags['nova'], $tags['platform-update']],
'days_ago' => 10,
'featured' => true,
'pinned' => true,
],
[
'slug' => 'introducing-groups-in-nova',
'title' => 'Introducing Groups in Nova',
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
'category' => $categories['groups'],
'excerpt' => 'Groups give collaborative identities a real home across publishing, releases, events, and project storytelling.',
'content' => "# Introducing Groups\n\nGroups let creators publish under a shared identity while still keeping contributor context visible.\n\n## Why it matters\n\nGroups are more than labels. They support:\n\n- shared publishing\n- releases and milestones\n- recruiting and discovery\n- public storytelling\n\nThat makes Nova feel much more alive than a simple profile-only platform.",
'tags' => [$tags['groups'], $tags['nova']],
'days_ago' => 9,
'featured' => true,
'pinned' => false,
],
[
'slug' => 'how-studio-works-in-nova',
'title' => 'How Studio Works in Nova',
'type' => NewsArticle::TYPE_TUTORIAL,
'category' => $categories['tutorials'],
'excerpt' => 'A practical walkthrough of drafts, scheduling, content editing, and publishing flow inside Studio.',
'content' => "# How Studio Works\n\nStudio is the control layer for publishing in Nova.\n\n## Start with drafts\n\nUse Studio to prepare content, tune metadata, and move work through review before publishing.\n\n## Move with intent\n\nThe best results come from treating Studio as a workflow, not just a form. Draft, refine, preview, then publish.",
'tags' => [$tags['tutorial'], $tags['nova']],
'days_ago' => 7,
'featured' => false,
'pinned' => false,
],
[
'slug' => 'how-to-upload-your-first-artwork',
'title' => 'How to Upload Your First Artwork',
'type' => NewsArticle::TYPE_TUTORIAL,
'category' => $categories['tutorials'],
'excerpt' => 'A short guide to cleaner uploads, stronger metadata, and better first impressions.',
'content' => "# Upload Your First Artwork\n\nA strong upload starts with the basics:\n\n- a clear title\n- a readable description\n- accurate categories and tags\n- a polished thumbnail or cover\n\nGood publishing habits make discovery, search, and editorial coverage work better for you.",
'tags' => [$tags['tutorial']],
'days_ago' => 6,
'featured' => false,
'pinned' => false,
],
[
'slug' => 'creator-spotlight-building-a-recognizable-profile',
'title' => 'Creator Spotlight: Building a Recognizable Profile',
'type' => NewsArticle::TYPE_SPOTLIGHT,
'category' => $categories['spotlight'],
'excerpt' => 'Profiles work better when identity, consistency, and a clear body of work all point in the same direction.',
'content' => "# Creator Spotlight\n\nGreat profiles are memorable because they feel intentional.\n\n## Strong profile signals\n\n- consistent visual identity\n- complete bio and links\n- a curated set of standout uploads\n- a clear publishing rhythm\n\nNova rewards clarity and consistency.",
'tags' => [$tags['spotlight']],
'days_ago' => 4,
'featured' => false,
'pinned' => false,
],
[
'slug' => 'release-roundup-whats-new-this-week',
'title' => 'Release Roundup: What\'s New This Week',
'type' => NewsArticle::TYPE_RELEASE,
'category' => $categories['releases'],
'excerpt' => 'A compact editorial roundup of recent launches, notable drops, and community momentum.',
'content' => "# Release Roundup\n\nThis week\'s standout launches show why Nova needs editorial context alongside uploads.\n\nRelease coverage helps people discover:\n\n- the work itself\n- the team behind it\n- related projects and collections\n- where the story continues next",
'tags' => [$tags['release'], $tags['spotlight']],
'days_ago' => 2,
'featured' => false,
'pinned' => false,
],
];
foreach ($articles as $article) {
$record = NewsArticle::query()->firstOrNew(['slug' => $article['slug']]);
$record->forceFill([
'title' => $article['title'],
'slug' => $article['slug'],
'excerpt' => $article['excerpt'],
'content' => $article['content'],
'author_id' => $author->id,
'category_id' => $article['category']->id,
'type' => $article['type'],
'status' => 'published',
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
'published_at' => Carbon::now()->subDays($article['days_ago'])->setTime(10, 0),
'is_featured' => $article['featured'],
'is_pinned' => $article['pinned'],
'meta_title' => $article['title'],
'meta_description' => $article['excerpt'],
'deleted_at' => null,
])->save();
$record->tags()->sync(array_map(static fn (NewsTag $tag): int => (int) $tag->id, $article['tags']));
}
}
private function upsertCategory(string $name, string $slug, string $description): NewsCategory
{
return NewsCategory::query()->updateOrCreate(
['slug' => $slug],
[
'name' => $name,
'description' => $description,
'position' => 0,
'is_active' => true,
]
);
}
private function upsertTag(string $name, string $slug): NewsTag
{
return NewsTag::query()->updateOrCreate(
['slug' => $slug],
['name' => $name]
);
}
}