diff --git a/app/Domains/Analytics/Models/Click.php b/app/Domains/Analytics/Models/Click.php new file mode 100644 index 0000000..4d16290 --- /dev/null +++ b/app/Domains/Analytics/Models/Click.php @@ -0,0 +1,30 @@ + '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); + } +} diff --git a/app/Domains/Domain/Models/Domain.php b/app/Domains/Domain/Models/Domain.php new file mode 100644 index 0000000..bbdb84a --- /dev/null +++ b/app/Domains/Domain/Models/Domain.php @@ -0,0 +1,28 @@ + 'datetime', + 'ssl_issued_at' => 'datetime', + 'ssl_expires_at' => 'datetime', + 'is_default' => 'boolean', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class); + } + + public function isVerified(): bool + { + return $this->verified_at !== null; + } +} diff --git a/app/Domains/Link/Models/Link.php b/app/Domains/Link/Models/Link.php new file mode 100644 index 0000000..8d43237 --- /dev/null +++ b/app/Domains/Link/Models/Link.php @@ -0,0 +1,53 @@ + '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; + } +} diff --git a/app/Domains/Link/Models/LinkVariant.php b/app/Domains/Link/Models/LinkVariant.php new file mode 100644 index 0000000..867dd4f --- /dev/null +++ b/app/Domains/Link/Models/LinkVariant.php @@ -0,0 +1,16 @@ +belongsTo(Link::class); + } +} diff --git a/app/Domains/Subscription/Models/Plan.php b/app/Domains/Subscription/Models/Plan.php new file mode 100644 index 0000000..57feb37 --- /dev/null +++ b/app/Domains/Subscription/Models/Plan.php @@ -0,0 +1,20 @@ + 'array', + ]; + + public function workspaces(): HasMany + { + return $this->hasMany(\App\Domains\Workspace\Models\Workspace::class); + } +} diff --git a/app/Domains/Workspace/Models/Workspace.php b/app/Domains/Workspace/Models/Workspace.php new file mode 100644 index 0000000..1d6c0aa --- /dev/null +++ b/app/Domains/Workspace/Models/Workspace.php @@ -0,0 +1,51 @@ + '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); + } +} diff --git a/app/Domains/Workspace/Models/WorkspaceInvitation.php b/app/Domains/Workspace/Models/WorkspaceInvitation.php new file mode 100644 index 0000000..872e0a6 --- /dev/null +++ b/app/Domains/Workspace/Models/WorkspaceInvitation.php @@ -0,0 +1,36 @@ + 'datetime', + 'accepted_at' => 'datetime', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'invited_by'); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function isAccepted(): bool + { + return $this->accepted_at !== null; + } +} diff --git a/app/Domains/Workspace/Models/WorkspaceMember.php b/app/Domains/Workspace/Models/WorkspaceMember.php new file mode 100644 index 0000000..7bf77d7 --- /dev/null +++ b/app/Domains/Workspace/Models/WorkspaceMember.php @@ -0,0 +1,25 @@ + 'datetime', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 68f3a66..04f3efa 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,24 +5,25 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, SoftDeletes; - /** - * The attributes that are mass assignable. - * - * @var list - */ - protected $fillable = [ - 'name', - 'email', - 'password', - ]; + protected $guarded = ['id']; + + protected static function booted(): void + { + static::creating(function (self $user) { + if (empty($user->ulid)) { + $user->ulid = \Illuminate\Support\Str::ulid(); + } + }); + } /** * The attributes that should be hidden for serialization. @@ -43,7 +44,23 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', + 'two_factor_confirmed_at' => 'datetime', 'password' => 'hashed', ]; } + + 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); + } } diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php index 984f050..481ce23 100644 --- a/tests/Feature/ProfileTest.php +++ b/tests/Feature/ProfileTest.php @@ -69,7 +69,7 @@ test('user can delete their account', function () { ->assertRedirect('/'); $this->assertGuest(); - $this->assertNull($user->fresh()); + $this->assertNotNull($user->fresh()->deleted_at); }); test('correct password must be provided to delete account', function () {