3293 lines
96 KiB
Markdown
3293 lines
96 KiB
Markdown
# 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
|
|
<?php
|
|
|
|
namespace App\Domains\Workspace\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
|
|
|
class Workspace extends Model
|
|
{
|
|
use SoftDeletes, HasUlids;
|
|
|
|
protected $guarded = ['id', 'owner_id'];
|
|
|
|
protected $casts = [
|
|
'branding' => '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
|
|
<?php
|
|
|
|
namespace App\Domains\Link\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
|
|
|
class Link extends Model
|
|
{
|
|
use SoftDeletes, HasUlids;
|
|
|
|
protected $guarded = ['id', 'workspace_id', 'created_by'];
|
|
|
|
protected $casts = [
|
|
'rules' => '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
|
|
<?php
|
|
|
|
namespace App\Domains\Analytics\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class Click extends Model
|
|
{
|
|
public $timestamps = false;
|
|
|
|
protected $guarded = [];
|
|
|
|
protected $casts = [
|
|
'clicked_at' => '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
|
|
<?php
|
|
|
|
use Illuminate\Support\Facades\Hash;
|
|
|
|
it('uses argon2id for password hashing', function () {
|
|
$hash = Hash::make('password');
|
|
expect(password_get_info($hash)['algoName'])->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
|
|
<?php
|
|
|
|
it('throttles login after 5 failed attempts', function () {
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$this->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
|
|
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\App;
|
|
|
|
class LocaleFromUser
|
|
{
|
|
public function handle(Request $request, Closure $next)
|
|
{
|
|
if ($user = $request->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
|
|
<?php
|
|
|
|
use App\Domains\Workspace\Actions\CreateWorkspace;
|
|
use App\Models\User;
|
|
|
|
it('creates a workspace and assigns owner as member', function () {
|
|
$user = User::factory()->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
|
|
<?php
|
|
|
|
namespace App\Domains\Workspace\Actions;
|
|
|
|
use App\Domains\Workspace\Models\Workspace;
|
|
use App\Domains\Workspace\Models\WorkspaceMember;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Str;
|
|
|
|
class CreateWorkspace
|
|
{
|
|
public function handle(User $owner, array $data): Workspace
|
|
{
|
|
$workspace = Workspace::create([
|
|
'ulid' => 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
|
|
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use App\Domains\Workspace\Models\Workspace;
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
|
|
class ResolveWorkspace
|
|
{
|
|
public function handle(Request $request, Closure $next)
|
|
{
|
|
$ulid = $request->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
|
|
<?php
|
|
|
|
namespace App\Domains\Workspace\Policies;
|
|
|
|
use App\Domains\Workspace\Models\Workspace;
|
|
use App\Models\User;
|
|
|
|
class WorkspacePolicy
|
|
{
|
|
public function view(User $user, Workspace $workspace): bool
|
|
{
|
|
return $this->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
|
|
<?php
|
|
|
|
use App\Domains\Link\Services\LinkCacheService;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
it('stores link data in redis hash', function () {
|
|
$service = new LinkCacheService;
|
|
|
|
$service->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
|
|
<?php
|
|
|
|
namespace App\Domains\Link\Services;
|
|
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
class LinkCacheService
|
|
{
|
|
private string $prefix = 'link:';
|
|
|
|
public function key(string $slug, ?int $domainId = null): string
|
|
{
|
|
return $domainId
|
|
? "{$this->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
|
|
<?php
|
|
|
|
namespace App\Domains\Link\Actions;
|
|
|
|
use App\Domains\Link\Models\Link;
|
|
use App\Domains\Link\Services\LinkCacheService;
|
|
use App\Domains\Workspace\Models\Workspace;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Str;
|
|
|
|
class CreateLink
|
|
{
|
|
public function __construct(private LinkCacheService $cache) {}
|
|
|
|
public function handle(Workspace $workspace, User $creator, array $data): Link
|
|
{
|
|
$link = Link::create([
|
|
'ulid' => 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
|
|
<?php
|
|
|
|
use App\Domains\Link\Models\Link;
|
|
use App\Domains\Workspace\Models\Workspace;
|
|
use App\Models\User;
|
|
|
|
it('redirects to target url for active link', function () {
|
|
$user = User::factory()->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
|
|
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Domains\Analytics\Jobs\RecordClickJob;
|
|
use App\Domains\Link\Models\Link;
|
|
use App\Domains\Link\Services\LinkCacheService;
|
|
use Illuminate\Http\Request;
|
|
|
|
class RedirectController extends Controller
|
|
{
|
|
public function __construct(private LinkCacheService $cache) {}
|
|
|
|
public function __invoke(Request $request, string $slug)
|
|
{
|
|
// 1. Redis-first lookup
|
|
$data = $this->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
|
|
<?php
|
|
|
|
use App\Domains\Analytics\Jobs\RecordClickJob;
|
|
use App\Domains\Analytics\Models\Click;
|
|
use App\Domains\Link\Models\Link;
|
|
|
|
it('records a click to the database', function () {
|
|
$link = Link::factory()->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
|
|
<?php
|
|
|
|
namespace App\Domains\Analytics\Jobs;
|
|
|
|
use App\Domains\Analytics\Models\Click;
|
|
use App\Domains\Link\Models\Link;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
class RecordClickJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable;
|
|
|
|
public function __construct(
|
|
public readonly int $linkId,
|
|
public readonly string $ip,
|
|
public readonly ?string $userAgent,
|
|
public readonly array $headers,
|
|
) {}
|
|
|
|
public function handle(): void
|
|
{
|
|
$link = Link::find($this->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
|
|
<?php
|
|
|
|
use App\Domains\Routing\Services\SmartRoutingEngine;
|
|
|
|
it('returns fallback when no rules match', function () {
|
|
$engine = new SmartRoutingEngine;
|
|
$rules = [
|
|
'rules' => [],
|
|
'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
|
|
<?php
|
|
|
|
namespace App\Domains\Routing\Services;
|
|
|
|
class SmartRoutingEngine
|
|
{
|
|
public function resolve(array $config, array $context): string
|
|
{
|
|
$rules = collect($config['rules'] ?? [])
|
|
->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
|
|
<?php
|
|
|
|
namespace App\Domains\Analytics\Jobs;
|
|
|
|
use App\Domains\Analytics\Models\Click;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class RollupHourlyJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable;
|
|
|
|
public function handle(): void
|
|
{
|
|
$targetHour = now()->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
|
|
<?php
|
|
|
|
namespace App\Domains\Analytics\Jobs;
|
|
|
|
use App\Domains\Analytics\Models\Click;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
|
|
class PruneRawClicksJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable;
|
|
|
|
public function handle(): void
|
|
{
|
|
Click::where('clicked_at', '<', now()->subDays(90))
|
|
->chunkById(1000, function ($clicks) {
|
|
$clicks->toQuery()->delete();
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Register schedule in routes/console.php**
|
|
|
|
```php
|
|
<?php
|
|
|
|
use Illuminate\Support\Facades\Schedule;
|
|
use App\Domains\Analytics\Jobs\{RollupHourlyJob, RollupDailyJob, PruneRawClicksJob};
|
|
use App\Console\Commands\{SyncStripe, ProvisionSsl, ResolveDomains};
|
|
|
|
Schedule::job(new RollupHourlyJob)->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
|
|
<?php
|
|
|
|
use App\Domains\Subscription\Services\PlanLimitsService;
|
|
use App\Domains\Workspace\Models\Workspace;
|
|
use App\Domains\Subscription\Models\Plan;
|
|
|
|
it('allows link creation when under limit', function () {
|
|
$plan = Plan::factory()->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
|
|
<?php
|
|
|
|
namespace App\Domains\Subscription\Services;
|
|
|
|
use App\Domains\Workspace\Models\Workspace;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
class PlanLimitsService
|
|
{
|
|
public function canCreateLink(Workspace $workspace): bool
|
|
{
|
|
$plan = $workspace->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
|
|
<?php
|
|
|
|
namespace Database\Seeders;
|
|
|
|
use App\Domains\Subscription\Models\Plan;
|
|
use Illuminate\Database\Seeder;
|
|
|
|
class PlanSeeder extends Seeder
|
|
{
|
|
public function run(): void
|
|
{
|
|
$plans = [
|
|
['name' => '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
|
|
<?php
|
|
|
|
use App\Domains\QrCode\Services\QrRenderer;
|
|
|
|
it('generates valid SVG for URL type', function () {
|
|
$renderer = new QrRenderer;
|
|
$svg = $renderer->toSvg('https://example.com', []);
|
|
|
|
expect($svg)->toContain('<svg')
|
|
->toContain('</svg>');
|
|
});
|
|
|
|
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
|
|
<?php
|
|
|
|
namespace App\Domains\QrCode\Services;
|
|
|
|
use chillerlan\QRCode\QRCode;
|
|
use chillerlan\QRCode\QROptions;
|
|
use chillerlan\QRCode\Output\QROutputInterface;
|
|
|
|
class QrRenderer
|
|
{
|
|
public function toSvg(string $data, array $style): string
|
|
{
|
|
$options = new QROptions([
|
|
'outputType' => 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
|
|
<?php
|
|
|
|
use App\Domains\Translation\Models\Translation;
|
|
use Illuminate\Support\Facades\App;
|
|
|
|
it('loads translation from database via __()', function () {
|
|
Translation::create([
|
|
'locale' => '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
|
|
<?php
|
|
|
|
namespace App\Domains\Translation\Providers;
|
|
|
|
use App\Domains\Translation\Models\Translation;
|
|
use Illuminate\Contracts\Translation\Loader;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
class DatabaseTranslationLoader implements Loader
|
|
{
|
|
public function load(string $locale, string $group, ?string $namespace = null): array
|
|
{
|
|
$cacheKey = "lang:{$locale}:{$group}";
|
|
|
|
$cached = Redis::hGetAll($cacheKey);
|
|
if (! empty($cached)) {
|
|
return $cached;
|
|
}
|
|
|
|
$translations = Translation::where('locale', $locale)
|
|
->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
|
|
<?php
|
|
|
|
it('handles subscription_created webhook', function () {
|
|
$workspace = \App\Domains\Workspace\Models\Workspace::factory()->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
|
|
<?php
|
|
|
|
use App\Domains\Link\Models\Link;
|
|
use App\Domains\Workspace\Models\Workspace;
|
|
use App\Models\User;
|
|
|
|
it('lists links for authenticated workspace', function () {
|
|
$user = User::factory()->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
|
|
<?php
|
|
|
|
namespace App\Domains\Api\Resources;
|
|
|
|
use Illuminate\Http\Resources\Json\JsonResource;
|
|
|
|
class LinkResource extends JsonResource
|
|
{
|
|
public function toArray($request): array
|
|
{
|
|
return [
|
|
'id' => $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
|
|
<?php
|
|
|
|
namespace App\Http\Controllers\Api\V1;
|
|
|
|
use App\Domains\Api\Resources\LinkResource;
|
|
use App\Domains\Link\Actions\CreateLink;
|
|
use App\Domains\Link\Models\Link;
|
|
use App\Domains\Workspace\Models\Workspace;
|
|
use App\Http\Controllers\Controller;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
|
|
class LinkController extends Controller
|
|
{
|
|
public function index(Request $request, string $workspaceUlid): AnonymousResourceCollection
|
|
{
|
|
$workspace = Workspace::where('ulid', $workspaceUlid)->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
|
|
<?php
|
|
|
|
use Illuminate\Support\Facades\Route;
|
|
use App\Http\Controllers\Api\V1\LinkController;
|
|
use App\Http\Controllers\Api\V1\WorkspaceController;
|
|
|
|
Route::prefix('v1')->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
|
|
<?php
|
|
|
|
use App\Domains\Webhook\Models\Webhook;
|
|
use App\Domains\Webhook\Services\WebhookDispatcher;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
it('dispatches webhook with HMAC signature', function () {
|
|
Http::fake(['*' => 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
|
|
<?php
|
|
|
|
namespace App\Domains\Webhook\Services;
|
|
|
|
use App\Domains\Webhook\Jobs\DeliverWebhookJob;
|
|
use App\Domains\Webhook\Models\Webhook;
|
|
|
|
class WebhookDispatcher
|
|
{
|
|
public function dispatch(string $event, array $payload, int $workspaceId): void
|
|
{
|
|
Webhook::where('workspace_id', $workspaceId)
|
|
->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
|
|
<?php
|
|
|
|
namespace App\Domains\Webhook\Jobs;
|
|
|
|
use App\Domains\Webhook\Models\Webhook;
|
|
use App\Domains\Webhook\Models\WebhookDelivery;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
class DeliverWebhookJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable;
|
|
|
|
public int $tries = 5;
|
|
public int $backoff = 60;
|
|
|
|
public function __construct(
|
|
public readonly Webhook $webhook,
|
|
public readonly string $event,
|
|
public readonly array $payload,
|
|
) {}
|
|
|
|
public function handle(): void
|
|
{
|
|
$body = json_encode([
|
|
'event' => $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
|
|
<?php
|
|
|
|
it('sends HSTS header on all requests', function () {
|
|
$response = $this->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
|
|
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
|
|
class SecurityHeaders
|
|
{
|
|
public function handle(Request $request, Closure $next)
|
|
{
|
|
$response = $next($request);
|
|
|
|
$response->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
|
|
<!DOCTYPE html>
|
|
<html lang="{{ app()->getLocale() }}" data-theme="{{ auth()->user()?->theme ?? 'dark' }}">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{{ $title ?? 'Nimuli' }}</title>
|
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
@livewireStyles
|
|
</head>
|
|
<body class="bg-(--color-bg) text-(--color-t1) font-sans antialiased">
|
|
<div class="flex h-screen overflow-hidden">
|
|
<x-sidebar />
|
|
<div class="flex-1 flex flex-col overflow-hidden">
|
|
<x-topbar />
|
|
<main class="flex-1 overflow-y-auto p-6">
|
|
{{ $slot }}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
@livewireScripts
|
|
<livewire:modal />
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 2: Sidebar component**
|
|
|
|
`resources/views/components/sidebar.blade.php`:
|
|
```blade
|
|
@php
|
|
$workspace = app('current_workspace');
|
|
$nav = [
|
|
['label' => 'Dashboard', 'route' => 'dashboard', 'icon' => 'grid'],
|
|
['label' => 'Links', 'route' => 'links.index', 'icon' => 'link'],
|
|
['label' => 'QR Codes', 'route' => 'qr.index', 'icon' => 'qrcode'],
|
|
['label' => 'Link-in-Bio', 'route' => 'bio.index', 'icon' => 'layout'],
|
|
['label' => 'Analytics', 'route' => 'analytics.index', 'icon' => 'chart'],
|
|
['label' => 'Domains', 'route' => 'domains.index', 'icon' => 'globe'],
|
|
['label' => 'Settings', 'route' => 'settings.index', 'icon' => 'settings'],
|
|
];
|
|
@endphp
|
|
<aside class="w-60 flex-shrink-0 bg-(--color-s1) border-r border-(--color-b1) flex flex-col">
|
|
<!-- Logo -->
|
|
<div class="h-16 flex items-center px-5 border-b border-(--color-b1)">
|
|
<span class="text-lg font-bold text-(--color-t1) tracking-tight">Nimuli</span>
|
|
</div>
|
|
|
|
<!-- Workspace box -->
|
|
@if($workspace)
|
|
<div class="px-3 py-3 border-b border-(--color-b1)">
|
|
<div class="text-xs text-(--color-t2) uppercase tracking-wider mb-1">Workspace</div>
|
|
<div class="text-sm font-medium text-(--color-t1) truncate">{{ $workspace->name }}</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- Nav -->
|
|
<nav class="flex-1 px-2 py-3 space-y-0.5">
|
|
@foreach($nav as $item)
|
|
<a href="{{ route($item['route'], $workspace?->ulid ?? '') }}"
|
|
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors
|
|
{{ request()->routeIs($item['route'] . '*') ? 'bg-(--color-b2) text-(--color-t1)' : 'text-(--color-t2) hover:text-(--color-t1) hover:bg-(--color-b1)' }}">
|
|
<span class="w-4 h-4">@svg($item['icon'])</span>
|
|
{{ $item['label'] }}
|
|
</a>
|
|
@endforeach
|
|
</nav>
|
|
|
|
<!-- Plan box -->
|
|
@if($workspace?->plan)
|
|
<div class="px-3 py-3 border-t border-(--color-b1)">
|
|
<div class="text-xs text-(--color-t2)">Plan: {{ ucfirst($workspace->plan->name) }}</div>
|
|
</div>
|
|
@endif
|
|
</aside>
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add resources/views/layouts/ resources/views/components/ app/Livewire/Layouts/
|
|
git commit -m "feat(ui): app layout with sidebar, topbar, dual-theme"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 18: Links Livewire Page
|
|
|
|
**Files:**
|
|
- Create: `app/Livewire/Pages/Links/Index.php`
|
|
- Create: `resources/views/livewire/pages/links/index.blade.php`
|
|
- Create: `app/Livewire/Pages/Links/CreateModal.php`
|
|
|
|
- [ ] **Step 1: Links index component**
|
|
|
|
`app/Livewire/Pages/Links/Index.php`:
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Livewire\Pages\Links;
|
|
|
|
use App\Domains\Link\Models\Link;
|
|
use App\Domains\Link\Actions\CreateLink;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
class Index extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
public string $search = '';
|
|
public string $statusFilter = '';
|
|
|
|
public function render()
|
|
{
|
|
$workspace = app('current_workspace');
|
|
|
|
$links = Link::where('workspace_id', $workspace->id)
|
|
->when($this->search, fn($q) => $q->where(function($q) {
|
|
$q->where('title', 'like', "%{$this->search}%")
|
|
->orWhere('slug', 'like', "%{$this->search}%")
|
|
->orWhere('target_url', 'like', "%{$this->search}%");
|
|
}))
|
|
->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
|
|
->latest()
|
|
->paginate(20);
|
|
|
|
return view('livewire.pages.links.index', compact('links'))
|
|
->layout('layouts.app', ['title' => 'Links']);
|
|
}
|
|
|
|
public function deleteLink(int $id): void
|
|
{
|
|
$workspace = app('current_workspace');
|
|
$link = Link::where('id', $id)->where('workspace_id', $workspace->id)->firstOrFail();
|
|
$this->authorize('update', $workspace);
|
|
$link->delete();
|
|
$this->resetPage();
|
|
}
|
|
}
|
|
```
|
|
|
|
`resources/views/livewire/pages/links/index.blade.php`:
|
|
```blade
|
|
<div>
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-2xl font-bold text-(--color-t1)">Links</h1>
|
|
<button
|
|
x-on:click="$dispatch('openModal', {component: 'pages.links.create-modal'})"
|
|
class="px-4 py-2 bg-(--color-blue) text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
|
|
+ New Link
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Search + Filter -->
|
|
<div class="flex gap-3 mb-4">
|
|
<input wire:model.live.debounce.300ms="search"
|
|
type="text" placeholder="Search links..."
|
|
class="flex-1 px-3 py-2 rounded-lg bg-(--color-s2) border border-(--color-b1) text-(--color-t1) text-sm focus:outline-none focus:border-(--color-blue)" />
|
|
<select wire:model.live="statusFilter"
|
|
class="px-3 py-2 rounded-lg bg-(--color-s2) border border-(--color-b1) text-(--color-t1) text-sm focus:outline-none">
|
|
<option value="">All status</option>
|
|
<option value="active">Active</option>
|
|
<option value="disabled">Disabled</option>
|
|
<option value="expired">Expired</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Links table -->
|
|
<div class="bg-(--color-s2) rounded-xl border border-(--color-b1) overflow-hidden">
|
|
<table class="w-full text-sm">
|
|
<thead class="border-b border-(--color-b1)">
|
|
<tr class="text-(--color-t2) text-xs uppercase tracking-wider">
|
|
<th class="px-4 py-3 text-left">Link</th>
|
|
<th class="px-4 py-3 text-left">Target</th>
|
|
<th class="px-4 py-3 text-right">Clicks</th>
|
|
<th class="px-4 py-3 text-center">Status</th>
|
|
<th class="px-4 py-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-(--color-b1)">
|
|
@forelse($links as $link)
|
|
<tr class="hover:bg-(--color-b1) transition-colors">
|
|
<td class="px-4 py-3">
|
|
<div class="font-mono text-(--color-blue) text-sm">{{ config('SHORT_LINK_DOMAIN') }}/{{ $link->slug }}</div>
|
|
@if($link->title)
|
|
<div class="text-(--color-t2) text-xs mt-0.5">{{ $link->title }}</div>
|
|
@endif
|
|
</td>
|
|
<td class="px-4 py-3 text-(--color-t2) truncate max-w-xs">{{ $link->target_url }}</td>
|
|
<td class="px-4 py-3 text-right font-mono text-(--color-t1)">0</td>
|
|
<td class="px-4 py-3 text-center">
|
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium
|
|
{{ $link->status === 'active' ? 'bg-green-500/10 text-(--color-green)' : 'bg-(--color-b2) text-(--color-t2)' }}">
|
|
{{ $link->status }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-right">
|
|
<button wire:click="deleteLink({{ $link->id }})"
|
|
wire:confirm="Delete this link?"
|
|
class="text-(--color-red) text-xs hover:opacity-80">Delete</button>
|
|
</td>
|
|
</tr>
|
|
@empty
|
|
<tr>
|
|
<td colspan="5" class="px-4 py-12 text-center text-(--color-t2)">No links yet. Create your first link.</td>
|
|
</tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="mt-4">{{ $links->links() }}</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 2: Register routes for workspace pages**
|
|
|
|
In `routes/web.php`, add workspace-scoped routes:
|
|
```php
|
|
Route::middleware(['auth', 'verified'])->prefix('w/{workspace}')->name('w.')->group(function () {
|
|
Route::get('/dashboard', \App\Livewire\Pages\Dashboard::class)->name('dashboard');
|
|
Route::get('/links', \App\Livewire\Pages\Links\Index::class)->name('links.index');
|
|
Route::get('/qr', \App\Livewire\Pages\QrCodes\Index::class)->name('qr.index');
|
|
Route::get('/bio', \App\Livewire\Pages\Bio\Index::class)->name('bio.index');
|
|
Route::get('/analytics', \App\Livewire\Pages\Analytics\Index::class)->name('analytics.index');
|
|
Route::get('/domains', \App\Livewire\Pages\Domains\Index::class)->name('domains.index');
|
|
Route::get('/settings', \App\Livewire\Pages\Settings\Index::class)->name('settings.index');
|
|
Route::get('/billing', \App\Livewire\Pages\Billing\Index::class)->name('billing.index');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/Livewire/Pages/Links/ resources/views/livewire/pages/links/ routes/web.php
|
|
git commit -m "feat(ui): links index page with search, filter, delete"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 19: Analytics Dashboard (Live)
|
|
|
|
**Files:**
|
|
- Create: `app/Livewire/Pages/Analytics/Index.php`
|
|
- Create: `resources/views/livewire/pages/analytics/index.blade.php`
|
|
- Create: `app/Domains/Analytics/Events/ClickRecorded.php`
|
|
|
|
- [ ] **Step 1: ClickRecorded event for Reverb**
|
|
|
|
`app/Domains/Analytics/Events/ClickRecorded.php`:
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Domains\Analytics\Events;
|
|
|
|
use App\Domains\Analytics\Models\Click;
|
|
use Illuminate\Broadcasting\Channel;
|
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
|
use Illuminate\Foundation\Events\Dispatchable;
|
|
|
|
class ClickRecorded implements ShouldBroadcast
|
|
{
|
|
use Dispatchable, InteractsWithSockets;
|
|
|
|
public function __construct(public readonly Click $click) {}
|
|
|
|
public function broadcastOn(): Channel
|
|
{
|
|
return new Channel("workspace.{$this->click->workspace_id}.analytics");
|
|
}
|
|
|
|
public function broadcastAs(): string
|
|
{
|
|
return 'click.recorded';
|
|
}
|
|
|
|
public function broadcastWith(): array
|
|
{
|
|
return [
|
|
'link_id' => $this->click->link_id,
|
|
'country' => $this->click->country,
|
|
'device' => $this->click->device,
|
|
'clicked_at' => $this->click->clicked_at?->toISOString(),
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
Fire in `RecordClickJob::handle()` after Click::create():
|
|
```php
|
|
event(new \App\Domains\Analytics\Events\ClickRecorded($click));
|
|
```
|
|
|
|
- [ ] **Step 2: Analytics Livewire page**
|
|
|
|
`app/Livewire/Pages/Analytics/Index.php`:
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Livewire\Pages\Analytics;
|
|
|
|
use App\Domains\Analytics\Models\ClickDaily;
|
|
use Livewire\Component;
|
|
use Livewire\Attributes\On;
|
|
|
|
class Index extends Component
|
|
{
|
|
public array $recentClicks = [];
|
|
public int $totalToday = 0;
|
|
public int $totalThisMonth = 0;
|
|
|
|
public function mount(): void
|
|
{
|
|
$workspace = app('current_workspace');
|
|
$this->totalToday = $workspace->links()
|
|
->withCount(['clicks as today_count' => fn($q) => $q->whereDate('clicked_at', today())])
|
|
->get()
|
|
->sum('today_count');
|
|
}
|
|
|
|
#[On('echo:workspace.{current_workspace.id}.analytics,click.recorded')]
|
|
public function handleNewClick(array $data): void
|
|
{
|
|
array_unshift($this->recentClicks, $data);
|
|
$this->recentClicks = array_slice($this->recentClicks, 0, 10);
|
|
$this->totalToday++;
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.pages.analytics.index')
|
|
->layout('layouts.app', ['title' => 'Analytics']);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/Domains/Analytics/Events/ app/Livewire/Pages/Analytics/ resources/views/livewire/pages/analytics/
|
|
git commit -m "feat(analytics): live dashboard with Reverb WebSocket, click event broadcasting"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 20: AI Suite
|
|
|
|
**Files:**
|
|
- Create: `app/Domains/Ai/Services/AiService.php`
|
|
- Create: `app/Domains/Ai/Actions/GenerateSlug.php`
|
|
- Create: `app/Domains/Ai/Actions/GenerateAbVariants.php`
|
|
- Create: `tests/Unit/Ai/GenerateSlugTest.php`
|
|
|
|
- [ ] **Step 1: Write failing test**
|
|
|
|
`tests/Unit/Ai/GenerateSlugTest.php`:
|
|
```php
|
|
<?php
|
|
|
|
use App\Domains\Ai\Actions\GenerateSlug;
|
|
use App\Domains\Ai\Services\AiService;
|
|
use Mockery;
|
|
|
|
it('generates slug from URL via AI service', function () {
|
|
$mockAi = Mockery::mock(AiService::class);
|
|
$mockAi->shouldReceive('complete')
|
|
->once()
|
|
->andReturn('best-promo');
|
|
|
|
$action = new GenerateSlug($mockAi);
|
|
$slug = $action->handle('https://example.com/summer-sale-2026');
|
|
|
|
expect($slug)->toBe('best-promo');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Implement AiService (Anthropic + OpenAI adapter)**
|
|
|
|
`app/Domains/Ai/Services/AiService.php`:
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Domains\Ai\Services;
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
class AiService
|
|
{
|
|
public function complete(string $prompt, string $model = 'claude-sonnet-4-6'): string
|
|
{
|
|
$apiKey = config('services.anthropic.api_key');
|
|
|
|
if ($apiKey) {
|
|
$response = Http::withHeaders([
|
|
'x-api-key' => $apiKey,
|
|
'anthropic-version' => '2023-06-01',
|
|
'content-type' => 'application/json',
|
|
])->post('https://api.anthropic.com/v1/messages', [
|
|
'model' => $model,
|
|
'max_tokens' => 256,
|
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
|
]);
|
|
|
|
return trim($response->json('content.0.text') ?? '');
|
|
}
|
|
|
|
return '';
|
|
}
|
|
}
|
|
```
|
|
|
|
`app/Domains/Ai/Actions/GenerateSlug.php`:
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Domains\Ai\Actions;
|
|
|
|
use App\Domains\Ai\Services\AiService;
|
|
|
|
class GenerateSlug
|
|
{
|
|
public function __construct(private AiService $ai) {}
|
|
|
|
public function handle(string $targetUrl): string
|
|
{
|
|
$prompt = "Generate a short, memorable URL slug (3-8 chars, lowercase, hyphens only) for this URL: {$targetUrl}. Reply with ONLY the slug, no explanation.";
|
|
|
|
$slug = $this->ai->complete($prompt);
|
|
|
|
// Sanitize
|
|
return preg_replace('/[^a-z0-9-]/', '', strtolower($slug));
|
|
}
|
|
}
|
|
```
|
|
|
|
Run: `docker compose exec app vendor/bin/pest tests/Unit/Ai/ -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 3: Add Anthropic config**
|
|
|
|
`config/services.php`, add:
|
|
```php
|
|
'anthropic' => [
|
|
'api_key' => env('ANTHROPIC_API_KEY'),
|
|
],
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add app/Domains/Ai/ config/services.php tests/Unit/Ai/
|
|
git commit -m "feat(ai): AiService (Anthropic adapter), GenerateSlug action"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 21: 2FA (TOTP)
|
|
|
|
**Files:**
|
|
- Modify: `app/Models/User.php` (2FA methods)
|
|
- Create: `app/Livewire/Pages/Settings/TwoFactor.php`
|
|
- Create: `resources/views/livewire/pages/settings/two-factor.blade.php`
|
|
|
|
- [ ] **Step 1: Publish 2FA config**
|
|
|
|
```bash
|
|
docker compose exec app php artisan vendor:publish --provider="PragmaRX\Google2FALaravel\ServiceProvider"
|
|
```
|
|
|
|
- [ ] **Step 2: Add 2FA methods to User model**
|
|
|
|
In `app/Models/User.php`:
|
|
```php
|
|
use PragmaRX\Google2FA\Google2FA;
|
|
|
|
public function enableTwoFactor(string $secret): array
|
|
{
|
|
$recoveryCodes = collect(range(1, 8))->map(fn() => implode('-', [
|
|
strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
|
|
strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
|
|
]))->all();
|
|
|
|
$this->update([
|
|
'two_factor_secret' => encrypt($secret),
|
|
'two_factor_recovery' => encrypt(json_encode($recoveryCodes)),
|
|
]);
|
|
|
|
return $recoveryCodes;
|
|
}
|
|
|
|
public function disableTwoFactor(): void
|
|
{
|
|
$this->update([
|
|
'two_factor_secret' => null,
|
|
'two_factor_recovery' => null,
|
|
'two_factor_confirmed_at' => null,
|
|
]);
|
|
}
|
|
|
|
public function hasTwoFactorEnabled(): bool
|
|
{
|
|
return $this->two_factor_confirmed_at !== null;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write test for 2FA enable**
|
|
|
|
`tests/Feature/Settings/TwoFactorTest.php`:
|
|
```php
|
|
<?php
|
|
|
|
use App\Models\User;
|
|
|
|
it('enables 2FA and returns recovery codes', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$codes = $user->enableTwoFactor('JBSWY3DPEHPK3PXP');
|
|
|
|
expect($codes)->toHaveCount(8)
|
|
->and($user->fresh()->two_factor_secret)->not->toBeNull();
|
|
});
|
|
```
|
|
|
|
Run: `docker compose exec app vendor/bin/pest tests/Feature/Settings/ -v`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add app/Models/User.php app/Livewire/Pages/Settings/ tests/Feature/Settings/
|
|
git commit -m "feat(auth): TOTP 2FA enable/disable with recovery codes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 22: Link-in-Bio Pages
|
|
|
|
**Files:**
|
|
- Create: `app/Domains/Bio/Models/BioPage.php`
|
|
- Create: `app/Domains/Bio/Models/BioBlock.php`
|
|
- Create: `app/Domains/Bio/Actions/CreateBioPage.php`
|
|
- Create: `app/Http/Controllers/BioPageController.php` (public render)
|
|
|
|
- [ ] **Step 1: BioPage model with Spatie Translatable**
|
|
|
|
`app/Domains/Bio/Models/BioPage.php`:
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Domains\Bio\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
|
use Spatie\Translatable\HasTranslations;
|
|
|
|
class BioPage extends Model
|
|
{
|
|
use SoftDeletes, HasUlids, HasTranslations;
|
|
|
|
protected $guarded = ['id', 'workspace_id'];
|
|
public $translatable = ['title', 'description'];
|
|
|
|
protected $casts = ['theme' => 'array', 'is_published' => 'boolean'];
|
|
|
|
public function blocks()
|
|
{
|
|
return $this->hasMany(BioBlock::class)->orderBy('position');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Public bio page controller**
|
|
|
|
`app/Http/Controllers/BioPageController.php`:
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Domains\Bio\Models\BioPage;
|
|
use App\Domains\Domain\Models\Domain;
|
|
|
|
class BioPageController extends Controller
|
|
{
|
|
public function show(string $slug)
|
|
{
|
|
$host = request()->getHost();
|
|
$domain = Domain::where('hostname', $host)->first();
|
|
|
|
$page = BioPage::where('slug', $slug)
|
|
->where('domain_id', $domain?->id)
|
|
->where('is_published', true)
|
|
->with('blocks')
|
|
->firstOrFail();
|
|
|
|
return view('bio.show', compact('page'));
|
|
}
|
|
}
|
|
```
|
|
|
|
Add route in `web.php` for custom bio domains (after main routes):
|
|
```php
|
|
Route::get('/bio/{slug}', [\App\Http\Controllers\BioPageController::class, 'show'])->name('bio.public');
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/Domains/Bio/ app/Http/Controllers/BioPageController.php
|
|
git commit -m "feat(bio): Link-in-Bio pages with translatable fields, public render"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 23: Domain Management
|
|
|
|
**Files:**
|
|
- Create: `app/Domains/Domain/Models/Domain.php`
|
|
- Create: `app/Domains/Domain/Actions/CreateDomain.php`
|
|
- Create: `app/Domains/Domain/Actions/VerifyDomain.php`
|
|
- Create: `app/Domains/Domain/Jobs/VerifyPendingDomainsJob.php`
|
|
|
|
- [ ] **Step 1: Domain model**
|
|
|
|
`app/Domains/Domain/Models/Domain.php`:
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Domains\Domain\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
class Domain extends Model
|
|
{
|
|
protected $guarded = ['id', 'workspace_id'];
|
|
|
|
protected $casts = [
|
|
'verified_at' => 'datetime',
|
|
'ssl_issued_at' => 'datetime',
|
|
'ssl_expires_at' => 'datetime',
|
|
'is_default' => 'boolean',
|
|
];
|
|
|
|
public function workspace()
|
|
{
|
|
return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
|
|
}
|
|
|
|
public function isVerified(): bool
|
|
{
|
|
return $this->verified_at !== null;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: VerifyDomain action (DNS TXT check)**
|
|
|
|
`app/Domains/Domain/Actions/VerifyDomain.php`:
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Domains\Domain\Actions;
|
|
|
|
use App\Domains\Domain\Models\Domain;
|
|
|
|
class VerifyDomain
|
|
{
|
|
public function handle(Domain $domain): bool
|
|
{
|
|
$records = dns_get_record("_nimuli.{$domain->hostname}", DNS_TXT);
|
|
|
|
foreach ($records as $record) {
|
|
if (($record['txt'] ?? '') === "nimuli-verify={$domain->verification_token}") {
|
|
$domain->update([
|
|
'verified_at' => now(),
|
|
'ssl_status' => 'pending',
|
|
]);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write test for domain verification**
|
|
|
|
`tests/Feature/Domain/VerifyDomainTest.php`:
|
|
```php
|
|
<?php
|
|
|
|
use App\Domains\Domain\Actions\VerifyDomain;
|
|
use App\Domains\Domain\Models\Domain;
|
|
|
|
it('marks domain as verified when TXT record matches', function () {
|
|
$domain = Domain::factory()->create([
|
|
'hostname' => 'example.com',
|
|
'verification_token' => 'abc123',
|
|
]);
|
|
|
|
// Mock DNS lookup
|
|
$action = Mockery::mock(VerifyDomain::class)->makePartial();
|
|
// For unit test, just verify the action handles a true DNS response
|
|
|
|
expect($domain->isVerified())->toBeFalse();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add app/Domains/Domain/ tests/Feature/Domain/
|
|
git commit -m "feat(domains): Domain model, DNS TXT verification action"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 24: Full Test Suite + PHPStan
|
|
|
|
- [ ] **Step 1: Run full test suite**
|
|
|
|
```bash
|
|
docker compose exec app vendor/bin/pest --parallel --no-coverage
|
|
```
|
|
|
|
Expected: All tests PASS. Fix any failures before proceeding.
|
|
|
|
- [ ] **Step 2: Set up PHPStan level 6**
|
|
|
|
Create `phpstan.neon`:
|
|
```neon
|
|
includes:
|
|
- vendor/larastan/larastan/extension.neon
|
|
|
|
parameters:
|
|
paths:
|
|
- app
|
|
level: 6
|
|
checkMissingIterableValueType: false
|
|
```
|
|
|
|
Run: `docker compose exec app vendor/bin/phpstan analyse`
|
|
Expected: No errors (or fix errors before continuing)
|
|
|
|
- [ ] **Step 3: Run Pint code style**
|
|
|
|
```bash
|
|
docker compose exec app vendor/bin/pint
|
|
```
|
|
|
|
Expected: All files formatted.
|
|
|
|
- [ ] **Step 4: Final commit before UI polish**
|
|
|
|
```bash
|
|
git add .
|
|
git commit -m "fix(phpstan): resolve level 6 type errors"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 25: Phase 1 Launch Checklist
|
|
|
|
- [ ] Stripe Live mode keys in production .env
|
|
- [ ] `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` set
|
|
- [ ] Run `php artisan optimize` in production
|
|
- [ ] Sentry DSN configured (`SENTRY_LARAVEL_DSN`)
|
|
- [ ] Backup destination configured in `config/backup.php`
|
|
- [ ] DSGVO: Cookie banner enabled in workspace settings
|
|
- [ ] Run `php artisan db:seed --class=PlanSeeder` in production
|
|
- [ ] Create admin user via tinker
|
|
- [ ] Test Stripe webhooks with CLI: `stripe listen --forward-to ...`
|
|
- [ ] Verify all 5 domains resolve through NPM: app.nimuli.com, nimu.li, ws.nimuli.com, vite.nimuli.com, mail.intern.nimuli.com
|
|
- [ ] Smoke test redirect: create link, click, verify analytics
|
|
- [ ] Run `vendor/bin/pest --parallel` — all green
|
|
- [ ] Run `vendor/bin/phpstan analyse` — no errors
|
|
- [ ] `git push origin main` triggers CI pipeline
|
|
|
|
---
|
|
|
|
## Remaining Livewire Pages (implement after Tasks 1-24)
|
|
|
|
Each page follows the same pattern as Task 18 (Links). Create the Livewire class + Blade view:
|
|
|
|
| Page | Component Class | Route Name |
|
|
|------|-----------------|------------|
|
|
| Dashboard | `App\Livewire\Pages\Dashboard` | `w.dashboard` |
|
|
| QR Code Index | `App\Livewire\Pages\QrCodes\Index` | `w.qr.index` |
|
|
| Bio Pages Index | `App\Livewire\Pages\Bio\Index` | `w.bio.index` |
|
|
| Analytics | `App\Livewire\Pages\Analytics\Index` | `w.analytics.index` |
|
|
| Domains | `App\Livewire\Pages\Domains\Index` | `w.domains.index` |
|
|
| Workspace Settings | `App\Livewire\Pages\Settings\Index` | `w.settings.index` |
|
|
| Billing | `App\Livewire\Pages\Billing\Index` | `w.billing.index` |
|
|
| Workspace Members | `App\Livewire\Pages\Workspace\Members` | `w.workspace.members` |
|
|
|
|
For each: write Pest test first → implement component → implement view → commit.
|
|
|
|
---
|