feat(db): add all Phase 1 migrations

Creates all Nimuli domain tables: plans, workspaces, workspace_members,
workspace_invitations, billing_profiles, domains, links, link_variants,
qr_codes, bio_pages, bio_blocks, clicks (+ hourly/daily/by_referrer/by_utm
aggregates), translations, webhooks, webhook_deliveries. Also extends the
users table with ulid, 2FA fields, locale, theme, default_workspace_id,
and soft deletes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 00:04:01 +02:00
parent 25dddf5293
commit 23fd5a5e30
20 changed files with 581 additions and 0 deletions

View File

@ -13,11 +13,19 @@ return new class extends Migration
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('ulid', 26)->unique();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('two_factor_secret')->nullable();
$table->text('two_factor_recovery')->nullable();
$table->timestamp('two_factor_confirmed_at')->nullable();
$table->string('locale', 5)->default('de');
$table->enum('theme', ['dark', 'light', 'system'])->default('dark');
$table->unsignedBigInteger('default_workspace_id')->nullable();
$table->rememberToken();
$table->softDeletes();
$table->timestamps();
});

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('plans', function (Blueprint $table) {
$table->id();
$table->string('name', 32)->unique();
$table->string('stripe_price_id')->nullable();
$table->unsignedInteger('monthly_price_cents')->default(0);
$table->unsignedSmallInteger('workspace_limit')->default(1);
$table->unsignedSmallInteger('member_limit')->default(1);
$table->unsignedSmallInteger('custom_domain_limit')->default(0);
$table->unsignedInteger('link_limit')->default(50);
$table->unsignedBigInteger('click_limit_monthly')->default(1000);
$table->json('features')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('plans');
}
};

View File

@ -0,0 +1,31 @@
<?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('workspaces', function (Blueprint $table) {
$table->id();
$table->string('ulid', 26)->unique();
$table->string('name');
$table->string('slug', 64);
$table->foreignId('owner_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('plan_id')->nullable()->constrained('plans')->nullOnDelete();
$table->timestamp('trial_ends_at')->nullable();
$table->json('branding')->nullable();
$table->string('stripe_id')->nullable()->index();
$table->json('stripe_customer_metadata')->nullable();
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('workspaces');
}
};

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::create('workspace_members', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['owner', 'admin', 'editor', 'viewer'])->default('editor');
$table->foreignId('invited_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('joined_at')->nullable();
$table->timestamps();
$table->unique(['workspace_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('workspace_members');
}
};

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('workspace_invitations', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->string('email');
$table->enum('role', ['admin', 'editor', 'viewer'])->default('editor');
$table->string('token', 64)->unique();
$table->foreignId('invited_by')->constrained('users')->cascadeOnDelete();
$table->timestamp('expires_at');
$table->timestamp('accepted_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('workspace_invitations');
}
};

View File

@ -0,0 +1,31 @@
<?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('billing_profiles', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->unique()->constrained()->cascadeOnDelete();
$table->foreignId('billing_user_id')->constrained('users')->cascadeOnDelete();
$table->string('company_name')->nullable();
$table->string('vat_number', 30)->nullable();
$table->string('address_line_1');
$table->string('address_line_2')->nullable();
$table->string('city');
$table->string('postal_code', 20);
$table->string('country', 2);
$table->string('email_billing');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('billing_profiles');
}
};

View File

@ -0,0 +1,31 @@
<?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('domains', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->string('hostname', 253)->unique();
$table->enum('type', ['short', 'bio', 'both'])->default('short');
$table->timestamp('verified_at')->nullable();
$table->string('verification_token', 64)->nullable();
$table->enum('ssl_status', ['pending', 'active', 'failed'])->default('pending');
$table->timestamp('ssl_issued_at')->nullable();
$table->timestamp('ssl_expires_at')->nullable();
$table->boolean('is_default')->default(false);
$table->timestamps();
$table->index(['workspace_id', 'hostname']);
});
}
public function down(): void
{
Schema::dropIfExists('domains');
}
};

View File

@ -0,0 +1,42 @@
<?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('links', function (Blueprint $table) {
$table->id();
$table->string('ulid', 26)->unique();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('domain_id')->nullable()->constrained()->nullOnDelete();
$table->string('slug', 64);
$table->text('target_url');
$table->string('title')->nullable();
$table->text('description')->nullable();
$table->string('favicon_url')->nullable();
$table->enum('status', ['active', 'disabled', 'expired'])->default('active');
$table->string('password', 255)->nullable();
$table->timestamp('expires_at')->nullable();
$table->unsignedBigInteger('click_limit')->nullable();
$table->json('rules')->nullable();
$table->json('pixel_config')->nullable();
$table->json('tags')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->softDeletes();
$table->timestamps();
$table->unique(['domain_id', 'slug']);
$table->index('workspace_id');
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('links');
}
};

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::create('link_variants', function (Blueprint $table) {
$table->id();
$table->foreignId('link_id')->constrained()->cascadeOnDelete();
$table->string('label', 64);
$table->text('target_url');
$table->unsignedTinyInteger('weight')->default(50);
$table->unsignedBigInteger('clicks_count')->default(0);
$table->unsignedBigInteger('conversions_count')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('link_variants');
}
};

View File

@ -0,0 +1,32 @@
<?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('qr_codes', function (Blueprint $table) {
$table->id();
$table->string('ulid', 26)->unique();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('link_id')->nullable()->constrained()->nullOnDelete();
$table->enum('type', ['url', 'vcard', 'wifi', 'pdf', 'mp3', 'text'])->default('url');
$table->json('payload');
$table->json('style')->nullable();
$table->boolean('is_dynamic')->default(true);
$table->unsignedBigInteger('scan_limit')->nullable();
$table->timestamp('expires_at')->nullable();
$table->softDeletes();
$table->timestamps();
$table->index('workspace_id');
});
}
public function down(): void
{
Schema::dropIfExists('qr_codes');
}
};

View File

@ -0,0 +1,33 @@
<?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('bio_pages', function (Blueprint $table) {
$table->id();
$table->string('ulid', 26)->unique();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('domain_id')->nullable()->constrained()->nullOnDelete();
$table->string('slug', 64);
$table->json('title');
$table->json('description')->nullable();
$table->string('og_image')->nullable();
$table->json('theme')->nullable();
$table->boolean('is_published')->default(false);
$table->softDeletes();
$table->timestamps();
$table->unique(['domain_id', 'slug']);
$table->index('workspace_id');
});
}
public function down(): void
{
Schema::dropIfExists('bio_pages');
}
};

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::create('bio_blocks', function (Blueprint $table) {
$table->id();
$table->foreignId('bio_page_id')->constrained()->cascadeOnDelete();
$table->unsignedSmallInteger('position')->default(0);
$table->string('type', 32);
$table->json('config');
$table->timestamps();
$table->index(['bio_page_id', 'position']);
});
}
public function down(): void
{
Schema::dropIfExists('bio_blocks');
}
};

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('clicks', function (Blueprint $table) {
$table->bigIncrements('id');
$table->foreignId('link_id')->constrained()->cascadeOnDelete();
$table->foreignId('variant_id')->nullable()->constrained('link_variants')->nullOnDelete();
$table->unsignedBigInteger('workspace_id');
$table->timestamp('clicked_at')->useCurrent();
$table->string('ip_hash', 64);
$table->string('country', 2)->nullable();
$table->string('city', 64)->nullable();
$table->enum('device', ['desktop', 'mobile', 'tablet', 'bot', 'unknown'])->default('unknown');
$table->string('os', 32)->nullable();
$table->string('browser', 32)->nullable();
$table->string('referrer_host', 253)->nullable();
$table->string('referrer_path', 2048)->nullable();
$table->string('utm_source', 128)->nullable();
$table->string('utm_medium', 128)->nullable();
$table->string('utm_campaign', 128)->nullable();
$table->string('utm_content', 128)->nullable();
$table->string('utm_term', 128)->nullable();
$table->string('language', 10)->nullable();
$table->index('clicked_at');
$table->index(['link_id', 'clicked_at']);
$table->index(['workspace_id', 'clicked_at']);
});
}
public function down(): void
{
Schema::dropIfExists('clicks');
}
};

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('clicks_hourly', function (Blueprint $table) {
$table->id();
$table->foreignId('link_id')->constrained()->cascadeOnDelete();
$table->timestamp('hour');
$table->string('country', 2)->nullable();
$table->enum('device', ['desktop', 'mobile', 'tablet', 'bot', 'unknown'])->nullable();
$table->unsignedBigInteger('count')->default(0);
$table->unsignedBigInteger('unique_visitors')->default(0);
$table->unique(['link_id', 'hour', 'country', 'device']);
$table->index(['link_id', 'hour']);
});
}
public function down(): void
{
Schema::dropIfExists('clicks_hourly');
}
};

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('clicks_daily', function (Blueprint $table) {
$table->id();
$table->foreignId('link_id')->constrained()->cascadeOnDelete();
$table->date('day');
$table->string('country', 2)->nullable();
$table->enum('device', ['desktop', 'mobile', 'tablet', 'bot', 'unknown'])->nullable();
$table->unsignedBigInteger('count')->default(0);
$table->unsignedBigInteger('unique_visitors')->default(0);
$table->unique(['link_id', 'day', 'country', 'device']);
$table->index(['link_id', 'day']);
});
}
public function down(): void
{
Schema::dropIfExists('clicks_daily');
}
};

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::create('clicks_by_referrer', function (Blueprint $table) {
$table->id();
$table->foreignId('link_id')->constrained()->cascadeOnDelete();
$table->date('day');
$table->string('referrer_host', 253)->nullable();
$table->unsignedBigInteger('count')->default(0);
$table->unique(['link_id', 'day', 'referrer_host']);
$table->index(['link_id', 'day']);
});
}
public function down(): void
{
Schema::dropIfExists('clicks_by_referrer');
}
};

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('clicks_by_utm', function (Blueprint $table) {
$table->id();
$table->foreignId('link_id')->constrained()->cascadeOnDelete();
$table->date('day');
$table->string('utm_source', 128)->nullable();
$table->string('utm_medium', 128)->nullable();
$table->string('utm_campaign', 128)->nullable();
$table->unsignedBigInteger('count')->default(0);
$table->unique(['link_id', 'day', 'utm_source', 'utm_medium', 'utm_campaign'], 'clicks_by_utm_unique');
$table->index(['link_id', 'day']);
});
}
public function down(): void
{
Schema::dropIfExists('clicks_by_utm');
}
};

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('translations', function (Blueprint $table) {
$table->id();
$table->string('locale', 5);
$table->string('namespace', 32)->default('*');
$table->string('group', 64);
$table->string('key', 255);
$table->text('value');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['locale', 'namespace', 'group', 'key']);
$table->index(['locale', 'group']);
});
}
public function down(): void
{
Schema::dropIfExists('translations');
}
};

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::create('webhooks', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->string('url');
$table->string('secret', 64);
$table->json('events');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index('workspace_id');
});
}
public function down(): void
{
Schema::dropIfExists('webhooks');
}
};

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('webhook_deliveries', function (Blueprint $table) {
$table->id();
$table->foreignId('webhook_id')->constrained()->cascadeOnDelete();
$table->string('event', 64);
$table->text('payload');
$table->enum('status', ['success', 'failed', 'pending'])->default('pending');
$table->unsignedSmallInteger('response_code')->nullable();
$table->unsignedTinyInteger('attempts')->default(1);
$table->timestamps();
$table->index(['webhook_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('webhook_deliveries');
}
};