# Nimuli Phase 1 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the complete Nimuli Phase 1 product — short links, QR codes, Link-in-Bio, smart routing, A/B testing, analytics, billing, AI suite, REST API, and webhooks — ready for Stripe live-mode launch. **Architecture:** Domain-driven Laravel 12 monolith with Octane/FrankenPHP, Livewire v3 pages, Redis-backed redirect hot-path (<10ms p95), async click events via queue, Reverb for live dashboard. Single MySQL DB with workspace_id scoping on all tenant tables. **Tech Stack:** Laravel 12, PHP 8.4, Livewire v3, Alpine.js, Tailwind v4, FrankenPHP/Octane, MySQL 8, Redis 7, Reverb, Cashier/Stripe, Pest 3, PHPStan level 6. **Running commands:** All `php artisan` and `composer` run via `docker compose exec app`. All `npm` on host. Aliases from .bashrc available after `source ~/.bashrc`. --- ## File Map ``` app/ Domains/ Workspace/ Models, Actions, Policies, Events Domain/ Models, Actions, Jobs (SSL, verification) Link/ Models, Actions, Services/LinkCacheService QrCode/ Models, Actions, Services/QrRenderer Bio/ Models, Actions (bio pages + blocks) Analytics/ Models, Jobs (record, rollup, prune), Services/AnalyticsQuery Routing/ Services/SmartRoutingEngine, BotDetector, PixelInjector, AbTestingService Subscription/ Models/Plan+BillingProfile, Actions, Services/PlanLimits Translation/ Model, Services/TranslationService, Providers/DbLoader Ai/ Services/AiService, Actions (slug, ab, insight, anomaly) Webhook/ Models, Actions, Jobs/DeliverWebhook, Services/Dispatcher Api/ Resources (transformers) Http/ Controllers/ RedirectController (hot-path only) Controllers/Api/V1/ LinkController, WorkspaceController, AnalyticsController Middleware/ ResolveWorkspace, EnforceLimits, LocaleFromUser Livewire/ Layouts/ AppLayout, GuestLayout, PublicBioLayout Pages/ Dashboard, Links/*, QrCodes/*, Bio/*, Analytics/*, Workspace/*, Settings/*, Billing/* Console/Commands/ SyncStripe, ProvisionSsl, ResolveDomains database/migrations/ (all tables) routes/web.php, api.php tests/Feature/ tests/Unit/ ``` --- ## Task 1: Database Migrations — All Tables **Files:** - Create: `database/migrations/2026_05_15_300000_create_workspaces_table.php` - Create: `database/migrations/2026_05_15_300001_create_workspace_members_table.php` - Create: `database/migrations/2026_05_15_300002_create_workspace_invitations_table.php` - Create: `database/migrations/2026_05_15_300003_create_billing_profiles_table.php` - Create: `database/migrations/2026_05_15_300010_create_domains_table.php` - Create: `database/migrations/2026_05_15_300020_create_links_table.php` - Create: `database/migrations/2026_05_15_300021_create_link_variants_table.php` - Create: `database/migrations/2026_05_15_300030_create_qr_codes_table.php` - Create: `database/migrations/2026_05_15_300040_create_bio_pages_table.php` - Create: `database/migrations/2026_05_15_300041_create_bio_blocks_table.php` - Create: `database/migrations/2026_05_15_300050_create_clicks_table.php` - Create: `database/migrations/2026_05_15_300051_create_clicks_hourly_table.php` - Create: `database/migrations/2026_05_15_300052_create_clicks_daily_table.php` - Create: `database/migrations/2026_05_15_300053_create_clicks_by_referrer_table.php` - Create: `database/migrations/2026_05_15_300054_create_clicks_by_utm_table.php` - Create: `database/migrations/2026_05_15_300060_create_plans_table.php` - Create: `database/migrations/2026_05_15_300070_create_translations_table.php` - Create: `database/migrations/2026_05_15_300080_create_webhooks_table.php` - Create: `database/migrations/2026_05_15_300081_create_webhook_deliveries_table.php` - Modify: `database/migrations/0001_01_01_000000_create_users_table.php` (add nimuli columns) - [ ] **Step 1: Add Nimuli columns to users migration** Edit `database/migrations/0001_01_01_000000_create_users_table.php`, replace the `up()` body: ```php 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(); }); ``` - [ ] **Step 2: Create workspaces migration** ```bash docker compose exec app php artisan make:migration create_workspaces_table ``` Fill it: ```php 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(); // {logo, primary_color, accent_color} $table->string('stripe_id')->nullable()->index(); $table->json('stripe_customer_metadata')->nullable(); $table->softDeletes(); $table->timestamps(); }); ``` - [ ] **Step 3: Create workspace_members and workspace_invitations migrations** ```bash docker compose exec app php artisan make:migration create_workspace_members_table docker compose exec app php artisan make:migration create_workspace_invitations_table ``` workspace_members: ```php 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']); }); ``` workspace_invitations: ```php 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(); }); ``` - [ ] **Step 4: Create billing_profiles and domains migrations** billing_profiles: ```php 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(); }); ``` domains: ```php 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']); }); ``` - [ ] **Step 5: Create links and link_variants migrations** links: ```php 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'); }); ``` link_variants: ```php 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(); }); ``` - [ ] **Step 6: Create QR, Bio, and Analytics migrations** ```bash docker compose exec app php artisan make:migration create_qr_codes_table docker compose exec app php artisan make:migration create_bio_pages_table docker compose exec app php artisan make:migration create_bio_blocks_table docker compose exec app php artisan make:migration create_clicks_table docker compose exec app php artisan make:migration create_clicks_hourly_table docker compose exec app php artisan make:migration create_clicks_daily_table docker compose exec app php artisan make:migration create_clicks_by_referrer_table docker compose exec app php artisan make:migration create_clicks_by_utm_table ``` qr_codes: ```php 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'); }); ``` bio_pages: ```php 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'); // translatable $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'); }); ``` bio_blocks: ```php 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']); }); ``` clicks (append-only, no updates): ```php 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']); }); ``` clicks_hourly: ```php 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']); }); ``` clicks_daily (same pattern, `day` instead of `hour`), clicks_by_referrer (add `referrer_host`), clicks_by_utm (add `utm_source`, `utm_medium`, `utm_campaign`). - [ ] **Step 7: Create plans, translations, webhooks migrations** plans: ```php 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(); }); ``` translations: ```php 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']); }); ``` webhooks + webhook_deliveries follow spec schema. - [ ] **Step 8: Run fresh migration** ```bash docker compose exec app php artisan migrate:fresh ``` Expected: All tables created, no errors. If foreign key errors, check migration order (plans must exist before workspaces). - [ ] **Step 9: Commit** ```bash git add database/migrations/ git commit -m "feat(db): add all Phase 1 migrations" ``` --- ## Task 2: Core Models **Files:** - Create: `app/Domains/Workspace/Models/Workspace.php` - Create: `app/Domains/Workspace/Models/WorkspaceMember.php` - Create: `app/Domains/Workspace/Models/WorkspaceInvitation.php` - Create: `app/Domains/Domain/Models/Domain.php` - Create: `app/Domains/Link/Models/Link.php` - Create: `app/Domains/Link/Models/LinkVariant.php` - Create: `app/Domains/Analytics/Models/Click.php` - Create: `app/Domains/Subscription/Models/Plan.php` - Modify: `app/Models/User.php` - [ ] **Step 1: Create directory structure** ```bash mkdir -p app/Domains/{Workspace/Models,Domain/Models,Link/Models,QrCode/Models,Bio/Models,Analytics/Models,Routing/Services,Subscription/Models,Translation,Ai/Services,Webhook/Models,Api/Resources} mkdir -p app/Domains/{Workspace/{Actions,Policies},Domain/{Actions,Jobs},Link/{Actions,Services},Analytics/{Jobs,Services},Subscription/{Actions,Services},Webhook/{Actions,Jobs,Services}} ``` - [ ] **Step 2: Workspace model** `app/Domains/Workspace/Models/Workspace.php`: ```php 'array', 'stripe_customer_metadata' => 'array', 'trial_ends_at' => 'datetime', ]; public function owner(): BelongsTo { return $this->belongsTo(\App\Models\User::class, 'owner_id'); } public function members(): HasMany { return $this->hasMany(WorkspaceMember::class); } public function invitations(): HasMany { return $this->hasMany(WorkspaceInvitation::class); } public function links(): HasMany { return $this->hasMany(\App\Domains\Link\Models\Link::class); } public function domains(): HasMany { return $this->hasMany(\App\Domains\Domain\Models\Domain::class); } public function plan(): BelongsTo { return $this->belongsTo(\App\Domains\Subscription\Models\Plan::class); } } ``` - [ ] **Step 3: Link model** `app/Domains/Link/Models/Link.php`: ```php 'array', 'pixel_config' => 'array', 'tags' => 'array', 'expires_at' => 'datetime', ]; public function workspace(): BelongsTo { return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class); } public function domain(): BelongsTo { return $this->belongsTo(\App\Domains\Domain\Models\Domain::class); } public function variants(): HasMany { return $this->hasMany(LinkVariant::class); } public function clicks(): HasMany { return $this->hasMany(\App\Domains\Analytics\Models\Click::class); } public function isActive(): bool { if ($this->status !== 'active') { return false; } if ($this->expires_at && $this->expires_at->isPast()) { return false; } return true; } } ``` - [ ] **Step 4: Click model (append-only)** `app/Domains/Analytics/Models/Click.php`: ```php 'datetime', ]; // Prevent updates — clicks are append-only public function save(array $options = []): bool { if ($this->exists) { throw new \LogicException('Click records are append-only.'); } return parent::save($options); } public function link() { return $this->belongsTo(\App\Domains\Link\Models\Link::class); } } ``` - [ ] **Step 5: Update User model** Add to `app/Models/User.php`: ```php use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\SoftDeletes; // In class body: use HasUlids, SoftDeletes; protected $guarded = ['id']; protected $casts = [ // existing casts... 'two_factor_confirmed_at' => 'datetime', ]; public function workspaces() { return $this->hasMany(\App\Domains\Workspace\Models\Workspace::class, 'owner_id'); } public function defaultWorkspace() { return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class, 'default_workspace_id'); } public function workspaceMemberships() { return $this->hasMany(\App\Domains\Workspace\Models\WorkspaceMember::class); } ``` - [ ] **Step 6: Run tests (baseline)** ```bash docker compose exec app vendor/bin/pest --no-coverage ``` Expected: Existing tests pass. Fix any failures before continuing. - [ ] **Step 7: Commit** ```bash git add app/Domains/ app/Models/User.php git commit -m "feat(models): add core domain models" ``` --- ## Task 3: Auth Hardening **Files:** - Modify: `config/auth.php` - Modify: `config/hashing.php` - Create: `app/Http/Middleware/LocaleFromUser.php` - Modify: `bootstrap/app.php` (register middleware) - Modify: `routes/auth.php` (add rate limiting) - [ ] **Step 1: Write failing test for Argon2id hashing** `tests/Feature/Auth/HashingTest.php`: ```php toBe('argon2id'); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Auth/HashingTest.php -v` Expected: FAIL — default driver is bcrypt - [ ] **Step 2: Configure Argon2id** `config/hashing.php` — change driver: ```php 'driver' => 'argon2id', ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Auth/HashingTest.php -v` Expected: PASS - [ ] **Step 3: Write failing test for login rate limiting** `tests/Feature/Auth/LoginRateLimitTest.php`: ```php post('/login', ['email' => 'test@test.com', 'password' => 'wrong']); } $response = $this->post('/login', ['email' => 'test@test.com', 'password' => 'wrong']); $response->assertStatus(429); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Auth/LoginRateLimitTest.php -v` Expected: FAIL (default Breeze may allow more attempts) - [ ] **Step 4: Create LocaleFromUser middleware** `app/Http/Middleware/LocaleFromUser.php`: ```php user()) { App::setLocale($user->locale ?? config('app.locale')); } return $next($request); } } ``` Register in `bootstrap/app.php`: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ \App\Http\Middleware\LocaleFromUser::class, ]); }) ``` - [ ] **Step 5: Commit** ```bash git add config/hashing.php app/Http/Middleware/LocaleFromUser.php bootstrap/app.php tests/Feature/Auth/ git commit -m "feat(auth): argon2id hashing, locale middleware, login rate limiting" ``` --- ## Task 4: Workspace Domain — Create, Middleware, Policies **Files:** - Create: `app/Domains/Workspace/Actions/CreateWorkspace.php` - Create: `app/Domains/Workspace/Actions/InviteMember.php` - Create: `app/Domains/Workspace/Actions/AcceptInvitation.php` - Create: `app/Domains/Workspace/Policies/WorkspacePolicy.php` - Create: `app/Http/Middleware/ResolveWorkspace.php` - Create: `tests/Feature/Workspace/CreateWorkspaceTest.php` - [ ] **Step 1: Write failing test** `tests/Feature/Workspace/CreateWorkspaceTest.php`: ```php create(); $workspace = (new CreateWorkspace)->handle($user, ['name' => 'Acme Corp']); expect($workspace->name)->toBe('Acme Corp') ->and($workspace->owner_id)->toBe($user->id) ->and($workspace->ulid)->toHaveLength(26); $this->assertDatabaseHas('workspace_members', [ 'workspace_id' => $workspace->id, 'user_id' => $user->id, 'role' => 'owner', ]); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Workspace/CreateWorkspaceTest.php -v` Expected: FAIL — class not found - [ ] **Step 2: Implement CreateWorkspace action** `app/Domains/Workspace/Actions/CreateWorkspace.php`: ```php Str::ulid(), 'name' => $data['name'], 'slug' => Str::slug($data['name']), 'owner_id' => $owner->id, ]); WorkspaceMember::create([ 'workspace_id' => $workspace->id, 'user_id' => $owner->id, 'role' => 'owner', 'joined_at' => now(), ]); if (! $owner->default_workspace_id) { $owner->update(['default_workspace_id' => $workspace->id]); } return $workspace; } } ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Workspace/CreateWorkspaceTest.php -v` Expected: PASS - [ ] **Step 3: Create ResolveWorkspace middleware** `app/Http/Middleware/ResolveWorkspace.php`: ```php route('workspace'); if (! $ulid) { return $next($request); } $workspace = Workspace::where('ulid', $ulid)->firstOrFail(); // 404 instead of 403 — don't leak existence across workspace boundaries if (! $request->user()?->workspaceMemberships()->where('workspace_id', $workspace->id)->exists()) { abort(404); } $request->merge(['current_workspace' => $workspace]); app()->instance('current_workspace', $workspace); return $next($request); } } ``` - [ ] **Step 4: Write and implement WorkspacePolicy** `app/Domains/Workspace/Policies/WorkspacePolicy.php`: ```php hasRole($user, $workspace, ['owner', 'admin', 'editor', 'viewer']); } public function create(User $user): bool { return true; } public function update(User $user, Workspace $workspace): bool { return $this->hasRole($user, $workspace, ['owner', 'admin']); } public function delete(User $user, Workspace $workspace): bool { return $this->hasRole($user, $workspace, ['owner']); } public function manageBilling(User $user, Workspace $workspace): bool { return $this->hasRole($user, $workspace, ['owner']); } private function hasRole(User $user, Workspace $workspace, array $roles): bool { return $workspace->members() ->where('user_id', $user->id) ->whereIn('role', $roles) ->exists(); } } ``` Register in `app/Providers/AppServiceProvider.php`: ```php use Illuminate\Support\Facades\Gate; use App\Domains\Workspace\Models\Workspace; use App\Domains\Workspace\Policies\WorkspacePolicy; Gate::policy(Workspace::class, WorkspacePolicy::class); ``` - [ ] **Step 5: Run all workspace tests** ```bash docker compose exec app vendor/bin/pest tests/Feature/Workspace/ -v ``` Expected: All PASS - [ ] **Step 6: Commit** ```bash git add app/Domains/Workspace/ app/Http/Middleware/ResolveWorkspace.php tests/Feature/Workspace/ git commit -m "feat(workspace): create action, middleware, policies" ``` --- ## Task 5: Link Cache Service + Slug Generation **Files:** - Create: `app/Domains/Link/Services/LinkCacheService.php` - Create: `app/Domains/Link/Actions/CreateLink.php` - Create: `app/Domains/Link/Actions/UpdateLink.php` - Create: `app/Domains/Link/Actions/DeleteLink.php` - Create: `tests/Unit/Link/LinkCacheServiceTest.php` - [ ] **Step 1: Write failing test for link cache** `tests/Unit/Link/LinkCacheServiceTest.php`: ```php put('testslug', [ 'target' => 'https://example.com', 'status' => 'active', 'workspace_id' => 1, ]); $data = $service->get('testslug'); expect($data['target'])->toBe('https://example.com') ->and($data['status'])->toBe('active'); }); it('returns null for unknown slug', function () { $service = new LinkCacheService; expect($service->get('nonexistent-' . uniqid()))->toBeNull(); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Unit/Link/LinkCacheServiceTest.php -v` Expected: FAIL - [ ] **Step 2: Implement LinkCacheService** `app/Domains/Link/Services/LinkCacheService.php`: ```php prefix}{$domainId}:{$slug}" : "{$this->prefix}{$slug}"; } public function put(string $slug, array $data, ?int $domainId = null): void { $key = $this->key($slug, $domainId); Redis::hMSet($key, $data); // TTL: 24 hours for active links, shorter for expired Redis::expire($key, 86400); } public function get(string $slug, ?int $domainId = null): ?array { $key = $this->key($slug, $domainId); $data = Redis::hGetAll($key); return empty($data) ? null : $data; } public function forget(string $slug, ?int $domainId = null): void { Redis::del($this->key($slug, $domainId)); } public function warmFromLink(\App\Domains\Link\Models\Link $link): void { $this->put($link->slug, [ 'target' => $link->target_url, 'status' => $link->status, 'rules_json' => $link->rules ? json_encode($link->rules) : '', 'expires_at' => $link->expires_at?->timestamp ?? '', 'workspace_id' => $link->workspace_id, 'pixel_config' => $link->pixel_config ? json_encode($link->pixel_config) : '', 'link_id' => $link->id, ], $link->domain_id); } } ``` Run: `docker compose exec app vendor/bin/pest tests/Unit/Link/LinkCacheServiceTest.php -v` Expected: PASS - [ ] **Step 3: Implement CreateLink action** `app/Domains/Link/Actions/CreateLink.php`: ```php Str::ulid(), 'workspace_id' => $workspace->id, 'domain_id' => $data['domain_id'] ?? null, 'slug' => $data['slug'] ?? $this->generateSlug(), 'target_url' => $data['target_url'], 'title' => $data['title'] ?? null, 'description' => $data['description'] ?? null, 'status' => 'active', 'expires_at' => $data['expires_at'] ?? null, 'click_limit' => $data['click_limit'] ?? null, 'rules' => $data['rules'] ?? null, 'pixel_config' => $data['pixel_config'] ?? null, 'tags' => $data['tags'] ?? null, 'created_by' => $creator->id, 'updated_by' => $creator->id, ]); $this->cache->warmFromLink($link); return $link; } private function generateSlug(int $length = 6): string { do { $slug = Str::random($length); } while (Link::where('slug', $slug)->whereNull('domain_id')->exists()); return $slug; } } ``` - [ ] **Step 4: Run link tests** ```bash docker compose exec app vendor/bin/pest tests/ --filter=link -v ``` Expected: All PASS - [ ] **Step 5: Commit** ```bash git add app/Domains/Link/ tests/Unit/Link/ tests/Feature/Link/ git commit -m "feat(link): create action, cache service, slug generation" ``` --- ## Task 6: Redirect Controller (Hot Path) **Files:** - Create: `app/Http/Controllers/RedirectController.php` - Modify: `routes/web.php` - Create: `tests/Feature/Redirect/RedirectTest.php` - [ ] **Step 1: Write failing redirect test** `tests/Feature/Redirect/RedirectTest.php`: ```php create(); $workspace = \App\Domains\Workspace\Actions\CreateWorkspace::new()->handle($user, ['name' => 'Test']); $link = Link::factory()->create([ 'workspace_id' => $workspace->id, 'slug' => 'abc123', 'target_url' => 'https://example.com', 'status' => 'active', ]); $response = $this->withHeaders(['Host' => config('app.short_link_domain', 'nimu.li')]) ->get('/abc123'); $response->assertRedirect('https://example.com'); $response->assertStatus(302); }); it('returns 404 for unknown slug', function () { $response = $this->withHeaders(['Host' => 'nimu.li'])->get('/nonexistent-xyz'); $response->assertStatus(404); }); it('returns 410 for disabled link', function () { $user = User::factory()->create(); $workspace = \App\Domains\Workspace\Actions\CreateWorkspace::new()->handle($user, ['name' => 'Test']); Link::factory()->create([ 'workspace_id' => $workspace->id, 'slug' => 'disabled1', 'target_url' => 'https://example.com', 'status' => 'disabled', ]); $response = $this->withHeaders(['Host' => 'nimu.li'])->get('/disabled1'); $response->assertStatus(410); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Redirect/RedirectTest.php -v` Expected: FAIL - [ ] **Step 2: Implement RedirectController** `app/Http/Controllers/RedirectController.php`: ```php cache->get($slug); if ($data === null) { // Cache miss — try DB $link = Link::where('slug', $slug) ->whereNull('domain_id') // default domain ->first(); if (! $link) { abort(404); } $this->cache->warmFromLink($link); $data = $this->cache->get($slug); } // 2. Status check if (($data['status'] ?? '') !== 'active') { abort(410); } // 3. Expiry check if (! empty($data['expires_at']) && (int) $data['expires_at'] < time()) { abort(410); } // 4. Fire-and-forget click event RecordClickJob::dispatch((int) $data['link_id'], $request->ip(), $request->userAgent(), $request->headers->all()) ->onQueue('clicks'); // 5. 302 redirect return redirect($data['target'], 302); } } ``` - [ ] **Step 3: Add route for redirect service** In `routes/web.php`, add at top (before other routes): ```php // Redirect service — handles nimu.li and custom domains Route::domain(config('SHORT_LINK_DOMAIN', 'nimu.li'))->group(function () { Route::get('/{slug}', \App\Http\Controllers\RedirectController::class) ->where('slug', '[a-zA-Z0-9_-]+'); }); ``` Also add fallback route on main domain to RedirectController for custom domain slugs: ```php Route::get('/{slug}', \App\Http\Controllers\RedirectController::class) ->where('slug', '[a-zA-Z0-9_-]+') ->name('redirect'); ``` - [ ] **Step 4: Create Link factory** ```bash docker compose exec app php artisan make:factory LinkFactory --model="App\Domains\Link\Models\Link" ``` `database/factories/LinkFactory.php`: ```php public function definition(): array { return [ 'ulid' => \Illuminate\Support\Str::ulid(), 'workspace_id' => \App\Domains\Workspace\Models\Workspace::factory(), 'slug' => \Illuminate\Support\Str::random(6), 'target_url' => fake()->url(), 'status' => 'active', ]; } ``` - [ ] **Step 5: Run redirect tests** ```bash docker compose exec app vendor/bin/pest tests/Feature/Redirect/ -v ``` Expected: All PASS - [ ] **Step 6: Commit** ```bash git add app/Http/Controllers/RedirectController.php routes/web.php database/factories/ tests/Feature/Redirect/ git commit -m "feat(redirect): hot-path redirect controller with Redis cache" ``` --- ## Task 7: Click Recording **Files:** - Create: `app/Domains/Analytics/Jobs/RecordClickJob.php` - Create: `app/Domains/Analytics/Events/ClickRecorded.php` - Create: `tests/Feature/Analytics/RecordClickTest.php` - [ ] **Step 1: Write failing test** `tests/Feature/Analytics/RecordClickTest.php`: ```php create(); RecordClickJob::dispatchSync($link->id, '1.2.3.4', 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0)', []); $this->assertDatabaseCount('clicks', 1); $this->assertDatabaseHas('clicks', [ 'link_id' => $link->id, 'country' => null, 'device' => 'mobile', ]); }); it('hashes IP address before storage', function () { $link = Link::factory()->create(); RecordClickJob::dispatchSync($link->id, '1.2.3.4', 'test-agent', []); $click = Click::first(); expect($click->ip_hash)->not->toBe('1.2.3.4') ->and(strlen($click->ip_hash))->toBe(64); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Analytics/RecordClickTest.php -v` Expected: FAIL - [ ] **Step 2: Implement RecordClickJob** `app/Domains/Analytics/Jobs/RecordClickJob.php`: ```php linkId); if (! $link) { return; } $ipHash = $this->hashIp($this->ip); $device = $this->detectDevice($this->userAgent ?? ''); Click::create([ 'link_id' => $this->linkId, 'workspace_id' => $link->workspace_id, 'clicked_at' => now(), 'ip_hash' => $ipHash, 'device' => $device, 'os' => $this->detectOs($this->userAgent ?? ''), 'browser' => $this->detectBrowser($this->userAgent ?? ''), 'referrer_host' => $this->extractHost($this->headers['referer'][0] ?? null), 'utm_source' => $this->headers['utm_source'][0] ?? null, 'utm_medium' => $this->headers['utm_medium'][0] ?? null, 'utm_campaign' => $this->headers['utm_campaign'][0] ?? null, 'language' => substr($this->headers['accept-language'][0] ?? 'de', 0, 10), ]); // Increment Redis counter $date = now()->format('Y-m-d'); \Illuminate\Support\Facades\Redis::incr("clicks:{$this->linkId}:{$date}"); \Illuminate\Support\Facades\Redis::incr("clicks:{$link->workspace_id}:monthly:" . now()->format('Ym')); } private function hashIp(string $ip): string { $salt = Cache::remember('ip_salt:' . today()->format('Y-m-d'), 86400, fn() => bin2hex(random_bytes(16))); return hash('sha256', $ip . $salt); } private function detectDevice(string $ua): string { if (str_contains($ua, 'bot') || str_contains($ua, 'crawler')) { return 'bot'; } if (preg_match('/Mobile|iPhone|Android.*Mobile|Windows Phone/', $ua)) { return 'mobile'; } if (preg_match('/iPad|Android(?!.*Mobile)|Tablet/', $ua)) { return 'tablet'; } if (strlen($ua) === 0) { return 'unknown'; } return 'desktop'; } private function detectOs(string $ua): ?string { if (str_contains($ua, 'Windows')) return 'windows'; if (str_contains($ua, 'Mac OS')) return 'macos'; if (str_contains($ua, 'iPhone') || str_contains($ua, 'iPad')) return 'ios'; if (str_contains($ua, 'Android')) return 'android'; if (str_contains($ua, 'Linux')) return 'linux'; return null; } private function detectBrowser(string $ua): ?string { if (str_contains($ua, 'Chrome')) return 'chrome'; if (str_contains($ua, 'Firefox')) return 'firefox'; if (str_contains($ua, 'Safari') && ! str_contains($ua, 'Chrome')) return 'safari'; if (str_contains($ua, 'Edge')) return 'edge'; return null; } private function extractHost(?string $url): ?string { if (! $url) return null; $parsed = parse_url($url); return $parsed['host'] ?? null; } } ``` - [ ] **Step 3: Run analytics tests** ```bash docker compose exec app vendor/bin/pest tests/Feature/Analytics/ -v ``` Expected: All PASS - [ ] **Step 4: Commit** ```bash git add app/Domains/Analytics/ tests/Feature/Analytics/ git commit -m "feat(analytics): click recording job with IP hashing, device detection" ``` --- ## Task 8: Smart Routing Engine **Files:** - Create: `app/Domains/Routing/Services/SmartRoutingEngine.php` - Create: `tests/Unit/Routing/SmartRoutingEngineTest.php` - [ ] **Step 1: Write failing tests** `tests/Unit/Routing/SmartRoutingEngineTest.php`: ```php [], 'fallback' => 'https://fallback.com', ]; $ctx = ['country' => 'US', 'device' => 'desktop']; expect($engine->resolve($rules, $ctx))->toBe('https://fallback.com'); }); it('matches device rule', function () { $engine = new SmartRoutingEngine; $rules = [ 'rules' => [ [ 'id' => 'r1', 'priority' => 1, 'conditions' => [['field' => 'device', 'op' => 'in', 'value' => ['mobile']]], 'match' => 'all', 'target' => 'https://mobile.com', ], ], 'fallback' => 'https://fallback.com', ]; expect($engine->resolve($rules, ['device' => 'mobile']))->toBe('https://mobile.com'); expect($engine->resolve($rules, ['device' => 'desktop']))->toBe('https://fallback.com'); }); it('matches country IN condition', function () { $engine = new SmartRoutingEngine; $rules = [ 'rules' => [ [ 'id' => 'r1', 'priority' => 1, 'conditions' => [['field' => 'country', 'op' => 'in', 'value' => ['DE', 'AT', 'CH']]], 'match' => 'all', 'target' => 'https://dach.com', ], ], 'fallback' => 'https://fallback.com', ]; expect($engine->resolve($rules, ['country' => 'DE']))->toBe('https://dach.com'); expect($engine->resolve($rules, ['country' => 'US']))->toBe('https://fallback.com'); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Unit/Routing/ -v` Expected: FAIL - [ ] **Step 2: Implement SmartRoutingEngine** `app/Domains/Routing/Services/SmartRoutingEngine.php`: ```php sortBy('priority'); foreach ($rules as $rule) { if ($this->ruleMatches($rule, $context)) { return $rule['target']; } } return $config['fallback']; } private function ruleMatches(array $rule, array $context): bool { $conditions = $rule['conditions'] ?? []; $matchMode = $rule['match'] ?? 'all'; $results = array_map( fn($condition) => $this->evaluateCondition($condition, $context), $conditions ); return $matchMode === 'all' ? ! in_array(false, $results, true) : in_array(true, $results, true); } private function evaluateCondition(array $condition, array $context): bool { $field = $condition['field']; $op = $condition['op']; $expected = $condition['value']; $actual = $context[$field] ?? null; return match ($op) { 'equals' => $actual === $expected, 'not_equals' => $actual !== $expected, 'in' => in_array($actual, (array) $expected, true), 'not_in' => ! in_array($actual, (array) $expected, true), 'contains' => str_contains((string) $actual, (string) $expected), 'starts_with' => str_starts_with((string) $actual, (string) $expected), 'between' => $actual >= $expected[0] && $actual <= $expected[1], 'greater_than' => $actual > $expected, 'less_than' => $actual < $expected, default => false, }; } } ``` Run: `docker compose exec app vendor/bin/pest tests/Unit/Routing/ -v` Expected: All PASS - [ ] **Step 3: Commit** ```bash git add app/Domains/Routing/ tests/Unit/Routing/ git commit -m "feat(routing): smart routing engine with condition evaluator" ``` --- ## Task 9: Analytics Rollups + Pruning **Files:** - Create: `app/Domains/Analytics/Jobs/RollupHourlyJob.php` - Create: `app/Domains/Analytics/Jobs/RollupDailyJob.php` - Create: `app/Domains/Analytics/Jobs/PruneRawClicksJob.php` - Modify: `routes/console.php` (schedule) - [ ] **Step 1: RollupHourlyJob** `app/Domains/Analytics/Jobs/RollupHourlyJob.php`: ```php startOfHour()->subHour(); DB::statement(" INSERT INTO clicks_hourly (link_id, hour, country, device, count, unique_visitors) SELECT link_id, DATE_FORMAT(clicked_at, '%Y-%m-%d %H:00:00') AS hour, country, device, COUNT(*) AS count, COUNT(DISTINCT ip_hash) AS unique_visitors FROM clicks WHERE clicked_at >= ? AND clicked_at < ? GROUP BY link_id, hour, country, device ON DUPLICATE KEY UPDATE count = count + VALUES(count), unique_visitors = GREATEST(unique_visitors, VALUES(unique_visitors)) ", [$targetHour, $targetHour->copy()->addHour()]); } } ``` - [ ] **Step 2: PruneRawClicksJob** `app/Domains/Analytics/Jobs/PruneRawClicksJob.php`: ```php subDays(90)) ->chunkById(1000, function ($clicks) { $clicks->toQuery()->delete(); }); } } ``` - [ ] **Step 3: Register schedule in routes/console.php** ```php everyFifteenMinutes(); Schedule::job(new RollupDailyJob)->dailyAt('02:00'); Schedule::job(new PruneRawClicksJob)->dailyAt('03:00'); Schedule::command('backup:run')->at('00:00')->at('06:00')->at('12:00')->at('18:00'); ``` - [ ] **Step 4: Commit** ```bash git add app/Domains/Analytics/Jobs/ routes/console.php git commit -m "feat(analytics): hourly/daily rollups, 90-day raw prune, schedule" ``` --- ## Task 10: Plan Limits Service **Files:** - Create: `app/Domains/Subscription/Models/Plan.php` - Create: `app/Domains/Subscription/Services/PlanLimitsService.php` - Create: `app/Http/Middleware/EnforceLimits.php` - Create: `tests/Unit/Subscription/PlanLimitsServiceTest.php` - [ ] **Step 1: Write failing test** `tests/Unit/Subscription/PlanLimitsServiceTest.php`: ```php create(['link_limit' => 10]); $workspace = Workspace::factory()->create(['plan_id' => $plan->id]); $service = new PlanLimitsService; expect($service->canCreateLink($workspace))->toBeTrue(); }); it('denies link creation when at limit', function () { $plan = Plan::factory()->create(['link_limit' => 2]); $workspace = Workspace::factory()->create(['plan_id' => $plan->id]); \App\Domains\Link\Models\Link::factory()->count(2)->create(['workspace_id' => $workspace->id]); $service = new PlanLimitsService; expect($service->canCreateLink($workspace))->toBeFalse(); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Unit/Subscription/ -v` Expected: FAIL - [ ] **Step 2: Implement PlanLimitsService** `app/Domains/Subscription/Services/PlanLimitsService.php`: ```php plan; if (! $plan) { return false; } $count = $workspace->links()->count(); return $count < $plan->link_limit; } public function monthlyClicksUsed(Workspace $workspace): int { $key = "clicks:{$workspace->id}:monthly:" . now()->format('Ym'); return (int) Redis::get($key); } public function isClickLimitApproaching(Workspace $workspace): bool { $plan = $workspace->plan; if (! $plan) return false; $used = $this->monthlyClicksUsed($workspace); return $used >= ($plan->click_limit_monthly * 0.9); } public function isClickLimitExceeded(Workspace $workspace): bool { $plan = $workspace->plan; if (! $plan) return false; return $this->monthlyClicksUsed($workspace) >= $plan->click_limit_monthly; } } ``` Run: `docker compose exec app vendor/bin/pest tests/Unit/Subscription/ -v` Expected: All PASS - [ ] **Step 3: Seed the 4 plans** `database/seeders/PlanSeeder.php`: ```php 'free', 'monthly_price_cents' => 0, 'workspace_limit' => 1, 'member_limit' => 1, 'custom_domain_limit' => 0, 'link_limit' => 50, 'click_limit_monthly' => 1000], ['name' => 'pro', 'monthly_price_cents' => 1900, 'workspace_limit' => 1, 'member_limit' => 3, 'custom_domain_limit' => 1, 'link_limit' => 1000, 'click_limit_monthly' => 25000], ['name' => 'business', 'monthly_price_cents' => 7900, 'workspace_limit' => 3, 'member_limit' => 10, 'custom_domain_limit' => 5, 'link_limit' => 10000, 'click_limit_monthly' => 250000], ['name' => 'agency', 'monthly_price_cents' => 19900, 'workspace_limit' => 999,'member_limit' => 999,'custom_domain_limit' => 999,'link_limit' => 999999,'click_limit_monthly' => 5000000], ]; foreach ($plans as $plan) { Plan::updateOrCreate(['name' => $plan['name']], $plan); } } } ``` ```bash docker compose exec app php artisan db:seed --class=PlanSeeder ``` - [ ] **Step 4: Commit** ```bash git add app/Domains/Subscription/ database/seeders/PlanSeeder.php tests/Unit/Subscription/ git commit -m "feat(billing): plan limits service, plan seeder (free/pro/business/agency)" ``` --- ## Task 11: QR Code Generator **Files:** - Create: `app/Domains/QrCode/Models/QrCode.php` - Create: `app/Domains/QrCode/Actions/GenerateQrCode.php` - Create: `app/Domains/QrCode/Services/QrRenderer.php` - Create: `tests/Unit/QrCode/QrRendererTest.php` - [ ] **Step 1: Write failing test** `tests/Unit/QrCode/QrRendererTest.php`: ```php toSvg('https://example.com', []); expect($svg)->toContain(''); }); it('generates png as base64', function () { $renderer = new QrRenderer; $png = $renderer->toPngBase64('https://example.com', []); expect($png)->toStartWith('data:image/png;base64,'); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Unit/QrCode/ -v` Expected: FAIL - [ ] **Step 2: Implement QrRenderer** `app/Domains/QrCode/Services/QrRenderer.php`: ```php QRCode::OUTPUT_MARKUP_SVG, 'imageTransparent' => true, ]); if (isset($style['foreground'])) { $options->markupDark = $style['foreground']; } return (new QRCode($options))->render($data); } public function toPngBase64(string $data, array $style): string { $options = new QROptions([ 'outputType' => QRCode::OUTPUT_IMAGE_PNG, 'imageTransparent' => false, 'scale' => 10, ]); $png = (new QRCode($options))->render($data); return 'data:image/png;base64,' . base64_encode($png); } public function buildPayload(string $type, array $data): string { return match ($type) { 'url' => $data['url'], 'text' => $data['text'], 'wifi' => "WIFI:T:{$data['encryption']};S:{$data['ssid']};P:{$data['password']};;", 'vcard' => $this->buildVcard($data), default => $data['url'] ?? '', }; } private function buildVcard(array $d): string { return "BEGIN:VCARD\nVERSION:3.0\nFN:{$d['name']}\nEMAIL:{$d['email']}\nTEL:{$d['phone']}\nEND:VCARD"; } } ``` Run: `docker compose exec app vendor/bin/pest tests/Unit/QrCode/ -v` Expected: All PASS - [ ] **Step 3: Commit** ```bash git add app/Domains/QrCode/ tests/Unit/QrCode/ git commit -m "feat(qr): QR code renderer (SVG + PNG), vcard/wifi payload builders" ``` --- ## Task 12: Translation System **Files:** - Create: `app/Domains/Translation/Models/Translation.php` - Create: `app/Domains/Translation/Services/TranslationService.php` - Create: `app/Domains/Translation/Providers/DatabaseTranslationLoader.php` - Modify: `app/Providers/AppServiceProvider.php` - [ ] **Step 1: Write failing test** `tests/Feature/Translation/TranslationServiceTest.php`: ```php 'de', 'namespace' => '*', 'group' => 'auth', 'key' => 'login', 'value' => 'Anmelden', ]); App::setLocale('de'); // Force reload app('translator')->setLoaded([]); expect(__('auth.login'))->toBe('Anmelden'); }); ``` - [ ] **Step 2: Implement DatabaseTranslationLoader** `app/Domains/Translation/Providers/DatabaseTranslationLoader.php`: ```php where('group', $group) ->pluck('value', 'key') ->toArray(); if (! empty($translations)) { Redis::hMSet($cacheKey, $translations); Redis::expire($cacheKey, 3600); } return $translations; } public function addNamespace(string $namespace, string $hint): void {} public function addPath(string $path): void {} public function namespaces(): array { return []; } public function addJsonPath(string $path): void {} } ``` Register in `AppServiceProvider::boot()`: ```php $this->app->singleton('translation.loader', function ($app) { return new \App\Domains\Translation\Providers\DatabaseTranslationLoader; }); ``` - [ ] **Step 3: Commit** ```bash git add app/Domains/Translation/ tests/Feature/Translation/ git commit -m "feat(i18n): database-backed translation loader with Redis cache" ``` --- ## Task 13: Stripe Billing Integration **Files:** - Modify: `app/Models/User.php` (Billable on Workspace, not User) - Modify: `app/Domains/Workspace/Models/Workspace.php` (add Billable) - Create: `app/Domains/Subscription/Actions/CreateSubscription.php` - Create: `app/Domains/Subscription/Listeners/StripeWebhookListener.php` - Modify: `routes/web.php` (Cashier webhook route) - [ ] **Step 1: Add Billable to Workspace** In `app/Domains/Workspace/Models/Workspace.php`: ```php use Laravel\Cashier\Billable; class Workspace extends Model { use SoftDeletes, HasUlids, Billable; // ... } ``` - [ ] **Step 2: Configure Cashier** In `.env`: ``` CASHIER_MODEL=App\Domains\Workspace\Models\Workspace CASHIER_CURRENCY=eur CASHIER_CURRENCY_LOCALE=de_DE ``` In `config/cashier.php` (publish if needed): ```bash docker compose exec app php artisan vendor:publish --tag=cashier-config ``` - [ ] **Step 3: Stripe webhook route** In `routes/web.php`: ```php Route::post('/stripe/webhook', '\Laravel\Cashier\Http\Controllers\WebhookController@handleWebhook') ->name('cashier.webhook'); ``` Add to CSRF exclusion in `bootstrap/app.php`: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'stripe/*', ]); }) ``` - [ ] **Step 4: Write Stripe webhook test** `tests/Feature/Billing/StripeWebhookTest.php`: ```php create(); $payload = [ 'type' => 'customer.subscription.created', 'data' => [ 'object' => [ 'id' => 'sub_test123', 'customer' => 'cus_test123', 'status' => 'active', 'items' => ['data' => [['price' => ['id' => 'price_pro']]]], 'current_period_start' => now()->timestamp, 'current_period_end' => now()->addMonth()->timestamp, ] ] ]; // This test verifies the webhook endpoint exists and accepts POST $response = $this->postJson('/stripe/webhook', $payload, [ 'Stripe-Signature' => 'test', ]); // 400 because signature invalid — but endpoint exists $response->assertStatus(400); }); ``` - [ ] **Step 5: Commit** ```bash git add app/Domains/Subscription/ app/Domains/Workspace/Models/Workspace.php routes/web.php tests/Feature/Billing/ git commit -m "feat(billing): Cashier Billable on Workspace, Stripe webhook route" ``` --- ## Task 14: REST API v1 **Files:** - Create: `routes/api.php` (v1 routes) - Create: `app/Http/Controllers/Api/V1/LinkController.php` - Create: `app/Http/Controllers/Api/V1/WorkspaceController.php` - Create: `app/Domains/Api/Resources/LinkResource.php` - Create: `tests/Feature/Api/V1/LinkApiTest.php` - [ ] **Step 1: Write failing API test** `tests/Feature/Api/V1/LinkApiTest.php`: ```php create(); $workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']); Link::factory()->count(3)->create(['workspace_id' => $workspace->id]); $token = $user->createToken('api-test')->plainTextToken; $response = $this->withToken($token) ->getJson("/api/v1/workspaces/{$workspace->ulid}/links"); $response->assertOk() ->assertJsonCount(3, 'data'); }); it('creates link via API', function () { $user = User::factory()->create(); $workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']); $token = $user->createToken('api-test')->plainTextToken; $response = $this->withToken($token) ->postJson("/api/v1/workspaces/{$workspace->ulid}/links", [ 'target_url' => 'https://example.com', ]); $response->assertCreated() ->assertJsonPath('data.target_url', 'https://example.com'); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Api/ -v` Expected: FAIL - [ ] **Step 2: Create LinkResource** `app/Domains/Api/Resources/LinkResource.php`: ```php $this->ulid, 'slug' => $this->slug, 'target_url' => $this->target_url, 'short_url' => 'https://' . config('SHORT_LINK_DOMAIN', 'nimu.li') . '/' . $this->slug, 'status' => $this->status, 'title' => $this->title, 'tags' => $this->tags ?? [], 'created_at' => $this->created_at?->toISOString(), ]; } } ``` - [ ] **Step 3: Create API LinkController** `app/Http/Controllers/Api/V1/LinkController.php`: ```php firstOrFail(); $this->authorize('view', $workspace); $links = $workspace->links() ->latest() ->paginate(50); return LinkResource::collection($links); } public function store(Request $request, string $workspaceUlid, CreateLink $action): LinkResource { $workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail(); $this->authorize('update', $workspace); $validated = $request->validate([ 'target_url' => 'required|url|max:2048', 'slug' => 'nullable|string|max:64|alpha_dash', 'title' => 'nullable|string|max:255', 'tags' => 'nullable|array', ]); $link = $action->handle($workspace, $request->user(), $validated); return (new LinkResource($link))->response()->setStatusCode(201)->getData(true); } public function destroy(string $workspaceUlid, string $linkUlid): \Illuminate\Http\Response { $workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail(); $link = Link::where('ulid', $linkUlid)->where('workspace_id', $workspace->id)->firstOrFail(); $this->authorize('update', $workspace); $link->delete(); return response()->noContent(); } } ``` - [ ] **Step 4: Define API routes** `routes/api.php`: ```php middleware(['auth:sanctum', 'throttle:api'])->group(function () { Route::prefix('workspaces/{workspace}')->group(function () { Route::get('links', [LinkController::class, 'index']); Route::post('links', [LinkController::class, 'store']); Route::delete('links/{link}', [LinkController::class, 'destroy']); }); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Api/ -v` Expected: All PASS - [ ] **Step 5: Commit** ```bash git add app/Http/Controllers/Api/ app/Domains/Api/ routes/api.php tests/Feature/Api/ git commit -m "feat(api): REST API v1 — links CRUD with Sanctum auth" ``` --- ## Task 15: Outgoing Webhooks **Files:** - Create: `app/Domains/Webhook/Models/Webhook.php` - Create: `app/Domains/Webhook/Services/WebhookDispatcher.php` - Create: `app/Domains/Webhook/Jobs/DeliverWebhookJob.php` - Create: `tests/Feature/Webhook/WebhookDispatchTest.php` - [ ] **Step 1: Write failing test** `tests/Feature/Webhook/WebhookDispatchTest.php`: ```php Http::response('ok', 200)]); $webhook = Webhook::factory()->create([ 'url' => 'https://example.com/hook', 'secret' => 'mysecret', 'events' => ['click.recorded'], 'is_active' => true, ]); $dispatcher = new WebhookDispatcher; $dispatcher->dispatch('click.recorded', ['link_id' => 1], $webhook->workspace_id); Http::assertSent(function ($request) { return $request->hasHeader('X-Nimuli-Signature') && str_starts_with($request->header('X-Nimuli-Signature')[0], 'sha256='); }); }); ``` - [ ] **Step 2: Implement WebhookDispatcher** `app/Domains/Webhook/Services/WebhookDispatcher.php`: ```php where('is_active', true) ->whereJsonContains('events', $event) ->each(function (Webhook $webhook) use ($event, $payload) { DeliverWebhookJob::dispatch($webhook, $event, $payload) ->onQueue('default'); }); } } ``` `app/Domains/Webhook/Jobs/DeliverWebhookJob.php`: ```php $this->event, 'payload' => $this->payload, 'timestamp' => now()->toISOString(), ]); $signature = 'sha256=' . hash_hmac('sha256', $body, $this->webhook->secret); // SSRF protection — block private IP ranges $host = parse_url($this->webhook->url, PHP_URL_HOST); if ($this->isPrivateHost($host)) { return; } $response = Http::timeout(10) ->withHeaders([ 'Content-Type' => 'application/json', 'X-Nimuli-Signature' => $signature, 'X-Nimuli-Event' => $this->event, ]) ->post($this->webhook->url, json_decode($body, true)); WebhookDelivery::create([ 'webhook_id' => $this->webhook->id, 'event' => $this->event, 'payload' => $body, 'status' => $response->successful() ? 'success' : 'failed', 'response_code' => $response->status(), 'attempts' => $this->attempts(), ]); } private function isPrivateHost(?string $host): bool { if (! $host) return true; $ip = gethostbyname($host); return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false; } } ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Webhook/ -v` Expected: All PASS - [ ] **Step 3: Commit** ```bash git add app/Domains/Webhook/ tests/Feature/Webhook/ git commit -m "feat(webhooks): outgoing webhooks with HMAC signature, SSRF protection, retry" ``` --- ## Task 16: Security Headers + GDPR Middleware **Files:** - Create: `app/Http/Middleware/SecurityHeaders.php` - Modify: `bootstrap/app.php` - Create: `tests/Feature/Security/SecurityHeadersTest.php` - [ ] **Step 1: Write failing security test** `tests/Feature/Security/SecurityHeadersTest.php`: ```php get('/'); $response->assertHeader('Strict-Transport-Security'); $response->assertHeader('X-Content-Type-Options', 'nosniff'); $response->assertHeader('X-Frame-Options', 'DENY'); $response->assertHeader('Referrer-Policy', 'strict-origin'); }); ``` Run: `docker compose exec app vendor/bin/pest tests/Feature/Security/ -v` Expected: FAIL - [ ] **Step 2: Implement SecurityHeaders middleware** `app/Http/Middleware/SecurityHeaders.php`: ```php headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('X-Frame-Options', 'DENY'); $response->headers->set('Referrer-Policy', 'strict-origin'); $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); return $response; } } ``` Register in `bootstrap/app.php` web middleware stack. Run: `docker compose exec app vendor/bin/pest tests/Feature/Security/ -v` Expected: PASS - [ ] **Step 3: Commit** ```bash git add app/Http/Middleware/SecurityHeaders.php bootstrap/app.php tests/Feature/Security/ git commit -m "feat(security): HSTS, X-Frame-Options, CSP, Referrer-Policy headers" ``` --- ## Task 17: Livewire App Layout + Sidebar **Files:** - Create: `app/Livewire/Layouts/AppLayout.php` - Create: `resources/views/layouts/app.blade.php` - Create: `resources/views/components/sidebar.blade.php` - Create: `resources/views/components/topbar.blade.php` - [ ] **Step 1: App layout component** `resources/views/layouts/app.blade.php`: ```blade
| Link | Target | Clicks | Status | |
|---|---|---|---|---|
|
{{ config('SHORT_LINK_DOMAIN') }}/{{ $link->slug }}
@if($link->title)
{{ $link->title }}
@endif
|
{{ $link->target_url }} | 0 | {{ $link->status }} | |
| No links yet. Create your first link. | ||||