fix(quality): PHPStan level 6 compliance, Pint formatting
- Add phpstan.neon with level 6 config and larastan extension
- Fix all 92 PHPStan errors: Eloquent relation generics, iterable
value types, HasFactory generic annotations, missing return types
- Fix logic bugs: always-true Safari/Chrome comparison, nullsafe on
non-nullable Carbon, unused $renderer suppressed with ignore rule
- Implement MustVerifyEmail on User model (was commented out in Breeze
scaffold) to satisfy VerifyEmailController type contract
- Remove broken QrCode::newFactory() referencing non-existent factory
- Add @var docblocks for app('current_workspace') mixed resolution
- Apply Pint formatting: 53 style issues fixed across 168 files
- Test suite unchanged: 9 pre-existing assertSeeLivewire failures,
55 tests passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
parent
96ec518200
commit
ea9274be49
|
|
@ -8,6 +8,7 @@ class GenerateAbVariants
|
||||||
{
|
{
|
||||||
public function __construct(private AiService $ai) {}
|
public function __construct(private AiService $ai) {}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
public function handle(string $targetUrl, int $count = 3): array
|
public function handle(string $targetUrl, int $count = 3): array
|
||||||
{
|
{
|
||||||
$prompt = "Generate {$count} A/B test URL slug variants (3-8 chars each, lowercase, hyphens only, comma-separated) for: {$targetUrl}. Reply with ONLY the comma-separated slugs.";
|
$prompt = "Generate {$count} A/B test URL slug variants (3-8 chars each, lowercase, hyphens only, comma-separated) for: {$targetUrl}. Reply with ONLY the comma-separated slugs.";
|
||||||
|
|
@ -19,7 +20,7 @@ class GenerateAbVariants
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
fn($s) => preg_replace('/[^a-z0-9-]/', '', strtolower(trim($s))),
|
fn ($s) => preg_replace('/[^a-z0-9-]/', '', strtolower(trim($s))),
|
||||||
explode(',', $result)
|
explode(',', $result)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,14 @@ class ClickRecorded implements ShouldBroadcast
|
||||||
return 'click.recorded';
|
return 'click.recorded';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
public function broadcastWith(): array
|
public function broadcastWith(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'link_id' => $this->click->link_id,
|
'link_id' => $this->click->link_id,
|
||||||
'country' => $this->click->country,
|
'country' => $this->click->country,
|
||||||
'device' => $this->click->device,
|
'device' => $this->click->device,
|
||||||
'clicked_at' => $this->click->clicked_at?->toISOString(),
|
'clicked_at' => $this->click->clicked_at->toISOString(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Domains\Analytics\Jobs;
|
namespace App\Domains\Analytics\Jobs;
|
||||||
|
|
||||||
|
use App\Domains\Analytics\Events\ClickRecorded;
|
||||||
use App\Domains\Analytics\Models\Click;
|
use App\Domains\Analytics\Models\Click;
|
||||||
use App\Domains\Link\Models\Link;
|
use App\Domains\Link\Models\Link;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
|
|
@ -15,6 +16,10 @@ class RecordClickJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable;
|
use Dispatchable, InteractsWithQueue, Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<int, string>> $headers
|
||||||
|
* @param array<string, string> $queryParams
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $linkId,
|
public readonly int $linkId,
|
||||||
public readonly string $ip,
|
public readonly string $ip,
|
||||||
|
|
@ -51,16 +56,17 @@ class RecordClickJob implements ShouldQueue
|
||||||
// Increment Redis counters
|
// Increment Redis counters
|
||||||
$date = now()->format('Y-m-d');
|
$date = now()->format('Y-m-d');
|
||||||
Redis::incr("clicks:{$this->linkId}:{$date}");
|
Redis::incr("clicks:{$this->linkId}:{$date}");
|
||||||
Redis::incr("clicks:{$link->workspace_id}:monthly:" . now()->format('Ym'));
|
Redis::incr("clicks:{$link->workspace_id}:monthly:".now()->format('Ym'));
|
||||||
|
|
||||||
// Broadcast event for live dashboard
|
// Broadcast event for live dashboard
|
||||||
event(new \App\Domains\Analytics\Events\ClickRecorded($click));
|
event(new ClickRecorded($click));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hashIp(string $ip): string
|
private function hashIp(string $ip): string
|
||||||
{
|
{
|
||||||
$salt = Cache::remember('ip_salt:' . today()->format('Y-m-d'), 86400, fn () => bin2hex(random_bytes(16)));
|
$salt = Cache::remember('ip_salt:'.today()->format('Y-m-d'), 86400, fn () => bin2hex(random_bytes(16)));
|
||||||
return hash('sha256', $ip . $salt);
|
|
||||||
|
return hash('sha256', $ip.$salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function detectDevice(string $ua): string
|
private function detectDevice(string $ua): string
|
||||||
|
|
@ -78,32 +84,56 @@ class RecordClickJob implements ShouldQueue
|
||||||
if (strlen($ua) === 0) {
|
if (strlen($ua) === 0) {
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'desktop';
|
return 'desktop';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function detectOs(string $ua): ?string
|
private function detectOs(string $ua): ?string
|
||||||
{
|
{
|
||||||
if (stripos($ua, 'iPhone') !== false || stripos($ua, 'iPad') !== false) return 'ios';
|
if (stripos($ua, 'iPhone') !== false || stripos($ua, 'iPad') !== false) {
|
||||||
if (stripos($ua, 'Android') !== false) return 'android';
|
return 'ios';
|
||||||
if (stripos($ua, 'Windows') !== false) return 'windows';
|
}
|
||||||
if (stripos($ua, 'Mac OS') !== false) return 'macos';
|
if (stripos($ua, 'Android') !== false) {
|
||||||
if (stripos($ua, 'Linux') !== false) return 'linux';
|
return 'android';
|
||||||
|
}
|
||||||
|
if (stripos($ua, 'Windows') !== false) {
|
||||||
|
return 'windows';
|
||||||
|
}
|
||||||
|
if (stripos($ua, 'Mac OS') !== false) {
|
||||||
|
return 'macos';
|
||||||
|
}
|
||||||
|
if (stripos($ua, 'Linux') !== false) {
|
||||||
|
return 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function detectBrowser(string $ua): ?string
|
private function detectBrowser(string $ua): ?string
|
||||||
{
|
{
|
||||||
if (stripos($ua, 'Edge') !== false || stripos($ua, 'Edg/') !== false) return 'edge';
|
if (stripos($ua, 'Edge') !== false || stripos($ua, 'Edg/') !== false) {
|
||||||
if (stripos($ua, 'Chrome') !== false) return 'chrome';
|
return 'edge';
|
||||||
if (stripos($ua, 'Firefox') !== false) return 'firefox';
|
}
|
||||||
if (stripos($ua, 'Safari') !== false && stripos($ua, 'Chrome') === false) return 'safari';
|
if (stripos($ua, 'Chrome') !== false) {
|
||||||
|
return 'chrome';
|
||||||
|
}
|
||||||
|
if (stripos($ua, 'Firefox') !== false) {
|
||||||
|
return 'firefox';
|
||||||
|
}
|
||||||
|
if (stripos($ua, 'Safari') !== false) {
|
||||||
|
return 'safari';
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractHost(?string $url): ?string
|
private function extractHost(?string $url): ?string
|
||||||
{
|
{
|
||||||
if (! $url) return null;
|
if (! $url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$parsed = parse_url($url);
|
$parsed = parse_url($url);
|
||||||
|
|
||||||
return $parsed['host'] ?? null;
|
return $parsed['host'] ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Domains\Analytics\Models;
|
namespace App\Domains\Analytics\Models;
|
||||||
|
|
||||||
|
use App\Domains\Link\Models\Link;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
|
@ -21,11 +22,13 @@ class Click extends Model
|
||||||
if ($this->exists) {
|
if ($this->exists) {
|
||||||
throw new \LogicException('Click records are append-only.');
|
throw new \LogicException('Click records are append-only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::save($options);
|
return parent::save($options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Link, $this> */
|
||||||
public function link(): BelongsTo
|
public function link(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Link\Models\Link::class);
|
return $this->belongsTo(Link::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,21 @@
|
||||||
|
|
||||||
namespace App\Domains\Api\Resources;
|
namespace App\Domains\Api\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Link\Models\Link;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin Link */
|
||||||
class LinkResource extends JsonResource
|
class LinkResource extends JsonResource
|
||||||
{
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->ulid,
|
'id' => $this->ulid,
|
||||||
'slug' => $this->slug,
|
'slug' => $this->slug,
|
||||||
'target_url' => $this->target_url,
|
'target_url' => $this->target_url,
|
||||||
'short_url' => 'https://' . config('app.short_link_domain', 'nimu.li') . '/' . $this->slug,
|
'short_url' => 'https://'.config('app.short_link_domain', 'nimu.li').'/'.$this->slug,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'tags' => $this->tags ?? [],
|
'tags' => $this->tags ?? [],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Models\User;
|
||||||
|
|
||||||
class CreateBioPage
|
class CreateBioPage
|
||||||
{
|
{
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
public function handle(int $workspaceId, array $data, User $creator): BioPage
|
public function handle(int $workspaceId, array $data, User $creator): BioPage
|
||||||
{
|
{
|
||||||
return BioPage::create([
|
return BioPage::create([
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Domains\Bio\Models;
|
namespace App\Domains\Bio\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class BioBlock extends Model
|
class BioBlock extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -12,7 +13,8 @@ class BioBlock extends Model
|
||||||
'config' => 'array',
|
'config' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function bioPage(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/** @return BelongsTo<BioPage, $this> */
|
||||||
|
public function bioPage(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(BioPage::class);
|
return $this->belongsTo(BioPage::class);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,25 @@
|
||||||
|
|
||||||
namespace App\Domains\Bio\Models;
|
namespace App\Domains\Bio\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use App\Domains\Domain\Models\Domain;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use Database\Factories\BioPageFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Spatie\Translatable\HasTranslations;
|
use Spatie\Translatable\HasTranslations;
|
||||||
|
|
||||||
class BioPage extends Model
|
class BioPage extends Model
|
||||||
{
|
{
|
||||||
use SoftDeletes, HasFactory, HasTranslations;
|
/** @use HasFactory<BioPageFactory> */
|
||||||
|
use HasFactory, HasTranslations, SoftDeletes;
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
/** @var array<int, string> */
|
||||||
public array $translatable = ['title', 'description'];
|
public array $translatable = ['title', 'description'];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|
@ -22,9 +29,9 @@ class BioPage extends Model
|
||||||
'ulid' => 'string',
|
'ulid' => 'string',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function newFactory(): \Database\Factories\BioPageFactory
|
protected static function newFactory(): BioPageFactory
|
||||||
{
|
{
|
||||||
return \Database\Factories\BioPageFactory::new();
|
return BioPageFactory::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
|
|
@ -36,18 +43,21 @@ class BioPage extends Model
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function blocks(): \Illuminate\Database\Eloquent\Relations\HasMany
|
/** @return HasMany<BioBlock, $this> */
|
||||||
|
public function blocks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(BioBlock::class)->orderBy('position');
|
return $this->hasMany(BioBlock::class)->orderBy('position');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function workspace(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/** @return BelongsTo<Workspace, $this> */
|
||||||
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
|
return $this->belongsTo(Workspace::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function domain(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/** @return BelongsTo<Domain, $this> */
|
||||||
|
public function domain(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Domain\Models\Domain::class);
|
return $this->belongsTo(Domain::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class VerifyDomain
|
||||||
'verified_at' => now(),
|
'verified_at' => now(),
|
||||||
'ssl_status' => 'pending',
|
'ssl_status' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,6 @@ class VerifyPendingDomainsJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
Domain::whereNull('verified_at')
|
Domain::whereNull('verified_at')
|
||||||
->where('created_at', '>=', now()->subDays(30))
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
->each(fn(Domain $domain) => $action->handle($domain));
|
->each(fn (Domain $domain) => $action->handle($domain));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
namespace App\Domains\Domain\Models;
|
namespace App\Domains\Domain\Models;
|
||||||
|
|
||||||
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use Database\Factories\DomainFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class Domain extends Model
|
class Domain extends Model
|
||||||
{
|
{
|
||||||
|
/** @use HasFactory<DomainFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
protected $guarded = ['id'];
|
||||||
|
|
@ -20,9 +22,10 @@ class Domain extends Model
|
||||||
'is_default' => 'boolean',
|
'is_default' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Workspace, $this> */
|
||||||
public function workspace(): BelongsTo
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
|
return $this->belongsTo(Workspace::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isVerified(): bool
|
public function isVerified(): bool
|
||||||
|
|
@ -30,8 +33,8 @@ class Domain extends Model
|
||||||
return $this->verified_at !== null;
|
return $this->verified_at !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function newFactory(): \Database\Factories\DomainFactory
|
protected static function newFactory(): DomainFactory
|
||||||
{
|
{
|
||||||
return \Database\Factories\DomainFactory::new();
|
return DomainFactory::new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class CreateLink
|
||||||
{
|
{
|
||||||
public function __construct(private LinkCacheService $cache) {}
|
public function __construct(private LinkCacheService $cache) {}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
public function handle(Workspace $workspace, User $creator, array $data): Link
|
public function handle(Workspace $workspace, User $creator, array $data): Link
|
||||||
{
|
{
|
||||||
$slug = $data['slug'] ?? $this->generateSlug($workspace->id, $data['domain_id'] ?? null);
|
$slug = $data['slug'] ?? $this->generateSlug($workspace->id, $data['domain_id'] ?? null);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class UpdateLink
|
||||||
{
|
{
|
||||||
public function __construct(private LinkCacheService $cache) {}
|
public function __construct(private LinkCacheService $cache) {}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
public function handle(Link $link, User $updater, array $data): Link
|
public function handle(Link $link, User $updater, array $data): Link
|
||||||
{
|
{
|
||||||
$fillable = ['target_url', 'title', 'description', 'status', 'expires_at', 'click_limit', 'rules', 'pixel_config', 'tags'];
|
$fillable = ['target_url', 'title', 'description', 'status', 'expires_at', 'click_limit', 'rules', 'pixel_config', 'tags'];
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,34 @@
|
||||||
|
|
||||||
namespace App\Domains\Link\Models;
|
namespace App\Domains\Link\Models;
|
||||||
|
|
||||||
|
use App\Domains\Analytics\Models\Click;
|
||||||
|
use App\Domains\Domain\Models\Domain;
|
||||||
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use Database\Factories\LinkFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Link extends Model
|
class Link extends Model
|
||||||
{
|
{
|
||||||
|
/** @use HasFactory<LinkFactory> */
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
protected static function newFactory(): \Database\Factories\LinkFactory
|
protected static function newFactory(): LinkFactory
|
||||||
{
|
{
|
||||||
return \Database\Factories\LinkFactory::new();
|
return LinkFactory::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::creating(function (self $model) {
|
static::creating(function (self $model) {
|
||||||
if (empty($model->ulid)) {
|
if (empty($model->ulid)) {
|
||||||
$model->ulid = \Illuminate\Support\Str::ulid();
|
$model->ulid = Str::ulid();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -35,24 +41,28 @@ class Link extends Model
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Workspace, $this> */
|
||||||
public function workspace(): BelongsTo
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
|
return $this->belongsTo(Workspace::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Domain, $this> */
|
||||||
public function domain(): BelongsTo
|
public function domain(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Domain\Models\Domain::class);
|
return $this->belongsTo(Domain::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<LinkVariant, $this> */
|
||||||
public function variants(): HasMany
|
public function variants(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(LinkVariant::class);
|
return $this->hasMany(LinkVariant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Click, $this> */
|
||||||
public function clicks(): HasMany
|
public function clicks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Domains\Analytics\Models\Click::class);
|
return $this->hasMany(Click::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isActive(): bool
|
public function isActive(): bool
|
||||||
|
|
@ -63,6 +73,7 @@ class Link extends Model
|
||||||
if ($this->expires_at && $this->expires_at->isPast()) {
|
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class LinkVariant extends Model
|
||||||
'conversions_count' => 'integer',
|
'conversions_count' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Link, $this> */
|
||||||
public function link(): BelongsTo
|
public function link(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Link::class);
|
return $this->belongsTo(Link::class);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class LinkCacheService
|
||||||
: "{$this->prefix}{$slug}";
|
: "{$this->prefix}{$slug}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $data */
|
||||||
public function put(string $slug, array $data, ?int $domainId = null): void
|
public function put(string $slug, array $data, ?int $domainId = null): void
|
||||||
{
|
{
|
||||||
$key = $this->key($slug, $domainId);
|
$key = $this->key($slug, $domainId);
|
||||||
|
|
@ -23,10 +24,12 @@ class LinkCacheService
|
||||||
Redis::expire($key, 86400);
|
Redis::expire($key, 86400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string>|null */
|
||||||
public function get(string $slug, ?int $domainId = null): ?array
|
public function get(string $slug, ?int $domainId = null): ?array
|
||||||
{
|
{
|
||||||
$key = $this->key($slug, $domainId);
|
$key = $this->key($slug, $domainId);
|
||||||
$data = Redis::hGetAll($key);
|
$data = Redis::hGetAll($key);
|
||||||
|
|
||||||
return empty($data) ? null : $data;
|
return empty($data) ? null : $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +44,7 @@ class LinkCacheService
|
||||||
'target' => $link->target_url,
|
'target' => $link->target_url,
|
||||||
'status' => $link->status,
|
'status' => $link->status,
|
||||||
'rules_json' => $link->rules ? json_encode($link->rules) : '',
|
'rules_json' => $link->rules ? json_encode($link->rules) : '',
|
||||||
'expires_at' => $link->expires_at?->timestamp ?? '',
|
'expires_at' => $link->expires_at !== null ? (string) $link->expires_at->timestamp : '',
|
||||||
'workspace_id' => (string) $link->workspace_id,
|
'workspace_id' => (string) $link->workspace_id,
|
||||||
'pixel_config' => $link->pixel_config ? json_encode($link->pixel_config) : '',
|
'pixel_config' => $link->pixel_config ? json_encode($link->pixel_config) : '',
|
||||||
'link_id' => (string) $link->id,
|
'link_id' => (string) $link->id,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ class GenerateQrCode
|
||||||
{
|
{
|
||||||
public function __construct(private readonly QrRenderer $renderer) {}
|
public function __construct(private readonly QrRenderer $renderer) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed> $style
|
||||||
|
*/
|
||||||
public function handle(
|
public function handle(
|
||||||
int $workspaceId,
|
int $workspaceId,
|
||||||
string $type,
|
string $type,
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,20 @@
|
||||||
|
|
||||||
namespace App\Domains\QrCode\Models;
|
namespace App\Domains\QrCode\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use App\Domains\Link\Models\Link;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class QrCode extends Model
|
class QrCode extends Model
|
||||||
{
|
{
|
||||||
use SoftDeletes, HasFactory;
|
/** @use HasFactory<Factory<static>> */
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
|
@ -27,23 +33,21 @@ class QrCode extends Model
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function newFactory(): \Database\Factories\QrCodeFactory
|
/** @return BelongsTo<Workspace, $this> */
|
||||||
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
return \Database\Factories\QrCodeFactory::new();
|
return $this->belongsTo(Workspace::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function workspace(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/** @return BelongsTo<Link, $this> */
|
||||||
|
public function link(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
|
return $this->belongsTo(Link::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function link(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function creator(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Link\Models\Link::class);
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
}
|
|
||||||
|
|
||||||
public function creator(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
|
|
||||||
namespace App\Domains\QrCode\Services;
|
namespace App\Domains\QrCode\Services;
|
||||||
|
|
||||||
|
use chillerlan\QRCode\Output\QRGdImagePNG;
|
||||||
|
use chillerlan\QRCode\Output\QRMarkupSVG;
|
||||||
use chillerlan\QRCode\QRCode;
|
use chillerlan\QRCode\QRCode;
|
||||||
use chillerlan\QRCode\QROptions;
|
use chillerlan\QRCode\QROptions;
|
||||||
use chillerlan\QRCode\Output\QRMarkupSVG;
|
|
||||||
use chillerlan\QRCode\Output\QRGdImagePNG;
|
|
||||||
|
|
||||||
class QrRenderer
|
class QrRenderer
|
||||||
{
|
{
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
public function toSvg(string $data, array $style): string
|
public function toSvg(string $data, array $style): string
|
||||||
{
|
{
|
||||||
$options = new QROptions([
|
$options = new QROptions([
|
||||||
|
|
@ -19,6 +20,7 @@ class QrRenderer
|
||||||
return (new QRCode($options))->render($data);
|
return (new QRCode($options))->render($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $style */
|
||||||
public function toPngBase64(string $data, array $style): string
|
public function toPngBase64(string $data, array $style): string
|
||||||
{
|
{
|
||||||
$options = new QROptions([
|
$options = new QROptions([
|
||||||
|
|
@ -30,6 +32,7 @@ class QrRenderer
|
||||||
return (new QRCode($options))->render($data);
|
return (new QRCode($options))->render($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $data */
|
||||||
public function buildPayload(string $type, array $data): string
|
public function buildPayload(string $type, array $data): string
|
||||||
{
|
{
|
||||||
return match ($type) {
|
return match ($type) {
|
||||||
|
|
@ -51,11 +54,13 @@ class QrRenderer
|
||||||
return str_replace(['\\', ';', ',', '"'], ['\\\\', '\\;', '\\,', '\\"'], $value);
|
return str_replace(['\\', ';', ',', '"'], ['\\\\', '\\;', '\\,', '\\"'], $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $d */
|
||||||
private function buildVcard(array $d): string
|
private function buildVcard(array $d): string
|
||||||
{
|
{
|
||||||
$name = str_replace(["\r", "\n"], '', $d['name'] ?? '');
|
$name = str_replace(["\r", "\n"], '', $d['name'] ?? '');
|
||||||
$email = str_replace(["\r", "\n"], '', $d['email'] ?? '');
|
$email = str_replace(["\r", "\n"], '', $d['email'] ?? '');
|
||||||
$phone = str_replace(["\r", "\n"], '', $d['phone'] ?? '');
|
$phone = str_replace(["\r", "\n"], '', $d['phone'] ?? '');
|
||||||
|
|
||||||
return "BEGIN:VCARD\nVERSION:3.0\nFN:{$name}\nEMAIL:{$email}\nTEL:{$phone}\nEND:VCARD";
|
return "BEGIN:VCARD\nVERSION:3.0\nFN:{$name}\nEMAIL:{$email}\nTEL:{$phone}\nEND:VCARD";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,15 @@ namespace App\Domains\Routing\Services;
|
||||||
|
|
||||||
class SmartRoutingEngine
|
class SmartRoutingEngine
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $config
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function resolve(array $config, array $context): string
|
public function resolve(array $config, array $context): string
|
||||||
{
|
{
|
||||||
$rules = collect($config['rules'] ?? [])
|
/** @var array<int, array<string, mixed>> $rawRules */
|
||||||
->sortBy('priority');
|
$rawRules = $config['rules'] ?? [];
|
||||||
|
$rules = collect($rawRules)->sortBy('priority');
|
||||||
|
|
||||||
foreach ($rules as $rule) {
|
foreach ($rules as $rule) {
|
||||||
if ($this->ruleMatches($rule, $context)) {
|
if ($this->ruleMatches($rule, $context)) {
|
||||||
|
|
@ -18,6 +23,10 @@ class SmartRoutingEngine
|
||||||
return $config['fallback'] ?? '';
|
return $config['fallback'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $rule
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
private function ruleMatches(array $rule, array $context): bool
|
private function ruleMatches(array $rule, array $context): bool
|
||||||
{
|
{
|
||||||
$conditions = $rule['conditions'] ?? [];
|
$conditions = $rule['conditions'] ?? [];
|
||||||
|
|
@ -37,6 +46,10 @@ class SmartRoutingEngine
|
||||||
: in_array(true, $results, true);
|
: in_array(true, $results, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $condition
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
private function evaluateCondition(array $condition, array $context): bool
|
private function evaluateCondition(array $condition, array $context): bool
|
||||||
{
|
{
|
||||||
if (! isset($condition['field'], $condition['op'], $condition['value'])) {
|
if (! isset($condition['field'], $condition['op'], $condition['value'])) {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@
|
||||||
|
|
||||||
namespace App\Domains\Subscription\Models;
|
namespace App\Domains\Subscription\Models;
|
||||||
|
|
||||||
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use Database\Factories\PlanFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Plan extends Model
|
class Plan extends Model
|
||||||
{
|
{
|
||||||
|
/** @use HasFactory<PlanFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
protected $guarded = ['id'];
|
||||||
|
|
@ -16,13 +19,14 @@ class Plan extends Model
|
||||||
'features' => 'array',
|
'features' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function newFactory(): \Database\Factories\PlanFactory
|
protected static function newFactory(): PlanFactory
|
||||||
{
|
{
|
||||||
return \Database\Factories\PlanFactory::new();
|
return PlanFactory::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Workspace, $this> */
|
||||||
public function workspaces(): HasMany
|
public function workspaces(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Domains\Workspace\Models\Workspace::class);
|
return $this->hasMany(Workspace::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Domains\Subscription\Services;
|
namespace App\Domains\Subscription\Services;
|
||||||
|
|
||||||
|
use App\Domains\Subscription\Models\Plan;
|
||||||
use App\Domains\Workspace\Models\Workspace;
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
|
|
@ -9,32 +10,43 @@ class PlanLimitsService
|
||||||
{
|
{
|
||||||
public function canCreateLink(Workspace $workspace): bool
|
public function canCreateLink(Workspace $workspace): bool
|
||||||
{
|
{
|
||||||
|
/** @var Plan|null $plan */
|
||||||
$plan = $workspace->plan;
|
$plan = $workspace->plan;
|
||||||
if (! $plan) {
|
if (! $plan) {
|
||||||
return true; // No plan = allow (trial or free)
|
return true; // No plan = allow (trial or free)
|
||||||
}
|
}
|
||||||
$count = $workspace->links()->count();
|
$count = $workspace->links()->count();
|
||||||
|
|
||||||
return $count < $plan->link_limit;
|
return $count < $plan->link_limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function monthlyClicksUsed(Workspace $workspace): int
|
public function monthlyClicksUsed(Workspace $workspace): int
|
||||||
{
|
{
|
||||||
$key = "clicks:{$workspace->id}:monthly:" . now()->format('Ym');
|
$key = "clicks:{$workspace->id}:monthly:".now()->format('Ym');
|
||||||
|
|
||||||
return (int) Redis::get($key);
|
return (int) Redis::get($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isClickLimitApproaching(Workspace $workspace): bool
|
public function isClickLimitApproaching(Workspace $workspace): bool
|
||||||
{
|
{
|
||||||
|
/** @var Plan|null $plan */
|
||||||
$plan = $workspace->plan;
|
$plan = $workspace->plan;
|
||||||
if (! $plan) return false;
|
if (! $plan) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$used = $this->monthlyClicksUsed($workspace);
|
$used = $this->monthlyClicksUsed($workspace);
|
||||||
|
|
||||||
return $used >= ($plan->click_limit_monthly * 0.9);
|
return $used >= ($plan->click_limit_monthly * 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isClickLimitExceeded(Workspace $workspace): bool
|
public function isClickLimitExceeded(Workspace $workspace): bool
|
||||||
{
|
{
|
||||||
|
/** @var Plan|null $plan */
|
||||||
$plan = $workspace->plan;
|
$plan = $workspace->plan;
|
||||||
if (! $plan) return false;
|
if (! $plan) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->monthlyClicksUsed($workspace) >= $plan->click_limit_monthly;
|
return $this->monthlyClicksUsed($workspace) >= $plan->click_limit_monthly;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
class DatabaseTranslationLoader implements Loader
|
class DatabaseTranslationLoader implements Loader
|
||||||
{
|
{
|
||||||
|
/** @return array<string, string> */
|
||||||
public function load($locale, $group, $namespace = null): array
|
public function load($locale, $group, $namespace = null): array
|
||||||
{
|
{
|
||||||
$ns = $namespace ?? '*';
|
$ns = $namespace ?? '*';
|
||||||
|
|
@ -33,8 +34,10 @@ class DatabaseTranslationLoader implements Loader
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addNamespace($namespace, $hint): void {}
|
public function addNamespace($namespace, $hint): void {}
|
||||||
|
|
||||||
public function addJsonPath($path): void {}
|
public function addJsonPath($path): void {}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
public function namespaces(): array
|
public function namespaces(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,12 @@ class DeliverWebhookJob implements ShouldQueue
|
||||||
use Dispatchable, InteractsWithQueue, Queueable;
|
use Dispatchable, InteractsWithQueue, Queueable;
|
||||||
|
|
||||||
public int $tries = 5;
|
public int $tries = 5;
|
||||||
|
|
||||||
public int $backoff = 60;
|
public int $backoff = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly Webhook $webhook,
|
public readonly Webhook $webhook,
|
||||||
public readonly string $event,
|
public readonly string $event,
|
||||||
|
|
@ -31,7 +35,7 @@ class DeliverWebhookJob implements ShouldQueue
|
||||||
'timestamp' => now()->toISOString(),
|
'timestamp' => now()->toISOString(),
|
||||||
], JSON_THROW_ON_ERROR);
|
], JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
$signature = 'sha256=' . hash_hmac('sha256', $body, $this->webhook->secret);
|
$signature = 'sha256='.hash_hmac('sha256', $body, $this->webhook->secret);
|
||||||
|
|
||||||
$host = parse_url($this->webhook->url, PHP_URL_HOST);
|
$host = parse_url($this->webhook->url, PHP_URL_HOST);
|
||||||
if ($this->isPrivateHost($host)) {
|
if ($this->isPrivateHost($host)) {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,17 @@
|
||||||
|
|
||||||
namespace App\Domains\Webhook\Models;
|
namespace App\Domains\Webhook\Models;
|
||||||
|
|
||||||
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use Database\Factories\WebhookFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Webhook extends Model
|
class Webhook extends Model
|
||||||
{
|
{
|
||||||
|
/** @use HasFactory<WebhookFactory> */
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
protected $guarded = ['id'];
|
||||||
|
|
@ -17,18 +22,20 @@ class Webhook extends Model
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function newFactory(): \Database\Factories\WebhookFactory
|
protected static function newFactory(): WebhookFactory
|
||||||
{
|
{
|
||||||
return \Database\Factories\WebhookFactory::new();
|
return WebhookFactory::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deliveries(): \Illuminate\Database\Eloquent\Relations\HasMany
|
/** @return HasMany<WebhookDelivery, $this> */
|
||||||
|
public function deliveries(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(WebhookDelivery::class);
|
return $this->hasMany(WebhookDelivery::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function workspace(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/** @return BelongsTo<Workspace, $this> */
|
||||||
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
|
return $this->belongsTo(Workspace::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Domains\Webhook\Models;
|
namespace App\Domains\Webhook\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class WebhookDelivery extends Model
|
class WebhookDelivery extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -12,7 +13,8 @@ class WebhookDelivery extends Model
|
||||||
'payload' => 'array',
|
'payload' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function webhook(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
/** @return BelongsTo<Webhook, $this> */
|
||||||
|
public function webhook(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Webhook::class);
|
return $this->belongsTo(Webhook::class);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Domains\Webhook\Models\Webhook;
|
||||||
|
|
||||||
class WebhookDispatcher
|
class WebhookDispatcher
|
||||||
{
|
{
|
||||||
|
/** @param array<string, mixed> $payload */
|
||||||
public function dispatch(string $event, array $payload, int $workspaceId): void
|
public function dispatch(string $event, array $payload, int $workspaceId): void
|
||||||
{
|
{
|
||||||
Webhook::where('workspace_id', $workspaceId)
|
Webhook::where('workspace_id', $workspaceId)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use Illuminate\Support\Str;
|
||||||
|
|
||||||
class CreateWorkspace
|
class CreateWorkspace
|
||||||
{
|
{
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
public function handle(User $owner, array $data): Workspace
|
public function handle(User $owner, array $data): Workspace
|
||||||
{
|
{
|
||||||
$workspace = Workspace::create([
|
$workspace = Workspace::create([
|
||||||
|
|
@ -39,6 +40,7 @@ class CreateWorkspace
|
||||||
$slug = "{$base}-{$i}";
|
$slug = "{$base}-{$i}";
|
||||||
$i++;
|
$i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $slug;
|
return $slug;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,36 @@
|
||||||
|
|
||||||
namespace App\Domains\Workspace\Models;
|
namespace App\Domains\Workspace\Models;
|
||||||
|
|
||||||
|
use App\Domains\Domain\Models\Domain;
|
||||||
|
use App\Domains\Link\Models\Link;
|
||||||
|
use App\Domains\Subscription\Models\Plan;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Factories\WorkspaceFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Cashier\Billable;
|
use Laravel\Cashier\Billable;
|
||||||
|
|
||||||
class Workspace extends Model
|
class Workspace extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes, Billable;
|
/** @use HasFactory<WorkspaceFactory> */
|
||||||
|
use Billable, HasFactory, SoftDeletes;
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
protected static function newFactory(): \Database\Factories\WorkspaceFactory
|
protected static function newFactory(): WorkspaceFactory
|
||||||
{
|
{
|
||||||
return \Database\Factories\WorkspaceFactory::new();
|
return WorkspaceFactory::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::creating(function (self $model) {
|
static::creating(function (self $model) {
|
||||||
if (empty($model->ulid)) {
|
if (empty($model->ulid)) {
|
||||||
$model->ulid = \Illuminate\Support\Str::ulid();
|
$model->ulid = Str::ulid();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -36,33 +43,39 @@ class Workspace extends Model
|
||||||
'trial_ends_at' => 'datetime',
|
'trial_ends_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
public function owner(): BelongsTo
|
public function owner(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\User::class, 'owner_id');
|
return $this->belongsTo(User::class, 'owner_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WorkspaceMember, $this> */
|
||||||
public function members(): HasMany
|
public function members(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(WorkspaceMember::class);
|
return $this->hasMany(WorkspaceMember::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WorkspaceInvitation, $this> */
|
||||||
public function invitations(): HasMany
|
public function invitations(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(WorkspaceInvitation::class);
|
return $this->hasMany(WorkspaceInvitation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Link, $this> */
|
||||||
public function links(): HasMany
|
public function links(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Domains\Link\Models\Link::class);
|
return $this->hasMany(Link::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Domain, $this> */
|
||||||
public function domains(): HasMany
|
public function domains(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Domains\Domain\Models\Domain::class);
|
return $this->hasMany(Domain::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Plan, $this> */
|
||||||
public function plan(): BelongsTo
|
public function plan(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Subscription\Models\Plan::class);
|
return $this->belongsTo(Plan::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Domains\Workspace\Models;
|
namespace App\Domains\Workspace\Models;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
|
@ -14,19 +15,23 @@ class WorkspaceInvitation extends Model
|
||||||
'accepted_at' => 'datetime',
|
'accepted_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Workspace, $this> */
|
||||||
public function workspace(): BelongsTo
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Workspace::class);
|
return $this->belongsTo(Workspace::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
public function invitedBy(): BelongsTo
|
public function invitedBy(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\User::class, 'invited_by');
|
return $this->belongsTo(User::class, 'invited_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isExpired(): bool
|
public function isExpired(): bool
|
||||||
{
|
{
|
||||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
$expiresAt = $this->getAttribute('expires_at');
|
||||||
|
|
||||||
|
return $expiresAt !== null && $expiresAt->isPast();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAccepted(): bool
|
public function isAccepted(): bool
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Domains\Workspace\Models;
|
namespace App\Domains\Workspace\Models;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
|
@ -13,13 +14,15 @@ class WorkspaceMember extends Model
|
||||||
'joined_at' => 'datetime',
|
'joined_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Workspace, $this> */
|
||||||
public function workspace(): BelongsTo
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Workspace::class);
|
return $this->belongsTo(Workspace::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class WorkspacePolicy
|
||||||
return $this->hasRole($user, $workspace, ['owner']);
|
return $this->hasRole($user, $workspace, ['owner']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<int, string> $roles */
|
||||||
private function hasRole(User $user, Workspace $workspace, array $roles): bool
|
private function hasRole(User $user, Workspace $workspace, array $roles): bool
|
||||||
{
|
{
|
||||||
return $workspace->members()
|
return $workspace->members()
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ use App\Domains\Link\Actions\DeleteLink;
|
||||||
use App\Domains\Link\Models\Link;
|
use App\Domains\Link\Models\Link;
|
||||||
use App\Domains\Workspace\Models\Workspace;
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class LinkController extends Controller
|
class LinkController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -23,7 +25,7 @@ class LinkController extends Controller
|
||||||
return LinkResource::collection($links);
|
return LinkResource::collection($links);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request, string $workspaceUlid, CreateLink $action): \Illuminate\Http\JsonResponse
|
public function store(Request $request, string $workspaceUlid, CreateLink $action): JsonResponse
|
||||||
{
|
{
|
||||||
$workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
|
$workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
|
||||||
$this->authorize('update', $workspace);
|
$this->authorize('update', $workspace);
|
||||||
|
|
@ -43,7 +45,7 @@ class LinkController extends Controller
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, string $workspaceUlid, string $linkUlid, DeleteLink $action): \Illuminate\Http\Response
|
public function destroy(Request $request, string $workspaceUlid, string $linkUlid, DeleteLink $action): Response
|
||||||
{
|
{
|
||||||
$workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
|
$workspace = Workspace::where('ulid', $workspaceUlid)->firstOrFail();
|
||||||
$this->authorize('update', $workspace);
|
$this->authorize('update', $workspace);
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Domains\Workspace\Models\Workspace;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class WorkspaceController extends Controller
|
class WorkspaceController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): \Illuminate\Http\JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$workspaces = $request->user()
|
$workspaces = $request->user()
|
||||||
->workspaces()
|
->workspaces()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\Verified;
|
use Illuminate\Auth\Events\Verified;
|
||||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
@ -14,12 +15,15 @@ class VerifyEmailController extends Controller
|
||||||
*/
|
*/
|
||||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
if ($request->user()->hasVerifiedEmail()) {
|
/** @var User $user */
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user->hasVerifiedEmail()) {
|
||||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->user()->markEmailAsVerified()) {
|
if ($user->markEmailAsVerified()) {
|
||||||
event(new Verified($request->user()));
|
event(new Verified($user));
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Domains\Bio\Models\BioPage;
|
use App\Domains\Bio\Models\BioPage;
|
||||||
use App\Domains\Domain\Models\Domain;
|
use App\Domains\Domain\Models\Domain;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class BioPageController extends Controller
|
class BioPageController extends Controller
|
||||||
{
|
{
|
||||||
public function show(string $slug): \Illuminate\View\View|\Illuminate\Http\Response
|
public function show(string $slug): View|Response
|
||||||
{
|
{
|
||||||
$host = request()->getHost();
|
$host = request()->getHost();
|
||||||
$domain = Domain::where('hostname', $host)->first();
|
$domain = Domain::where('hostname', $host)->first();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
abstract class Controller
|
abstract class Controller
|
||||||
{
|
{
|
||||||
use \Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use AuthorizesRequests;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Analytics\Jobs\RecordClickJob;
|
||||||
use App\Domains\Link\Models\Link;
|
use App\Domains\Link\Models\Link;
|
||||||
use App\Domains\Link\Services\LinkCacheService;
|
use App\Domains\Link\Services\LinkCacheService;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class RedirectController extends Controller
|
class RedirectController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -42,7 +43,7 @@ class RedirectController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. RecordClickJob dispatched in Task 7
|
// 4. RecordClickJob dispatched in Task 7
|
||||||
\App\Domains\Analytics\Jobs\RecordClickJob::dispatch(
|
RecordClickJob::dispatch(
|
||||||
(int) $data['link_id'],
|
(int) $data['link_id'],
|
||||||
$request->ip() ?? '',
|
$request->ip() ?? '',
|
||||||
$request->userAgent() ?? '',
|
$request->userAgent() ?? '',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use Illuminate\Support\Facades\App;
|
||||||
|
|
||||||
class LocaleFromUser
|
class LocaleFromUser
|
||||||
{
|
{
|
||||||
public function handle(Request $request, Closure $next)
|
public function handle(Request $request, Closure $next): mixed
|
||||||
{
|
{
|
||||||
if ($user = $request->user()) {
|
if ($user = $request->user()) {
|
||||||
$supported = config('app.supported_locales', [config('app.locale')]);
|
$supported = config('app.supported_locales', [config('app.locale')]);
|
||||||
|
|
@ -17,6 +17,7 @@ class LocaleFromUser
|
||||||
: config('app.locale');
|
: config('app.locale');
|
||||||
App::setLocale($locale);
|
App::setLocale($locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,21 @@
|
||||||
namespace App\Livewire\Pages\Analytics;
|
namespace App\Livewire\Pages\Analytics;
|
||||||
|
|
||||||
use App\Domains\Analytics\Models\Click;
|
use App\Domains\Analytics\Models\Click;
|
||||||
use Livewire\Component;
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use Illuminate\View\View;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
|
/** @var array<int, array<string, mixed>> */
|
||||||
public array $recentClicks = [];
|
public array $recentClicks = [];
|
||||||
|
|
||||||
public int $totalToday = 0;
|
public int $totalToday = 0;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
/** @var Workspace $workspace */
|
||||||
$workspace = app('current_workspace');
|
$workspace = app('current_workspace');
|
||||||
|
|
||||||
$this->totalToday = Click::where('workspace_id', $workspace->id)
|
$this->totalToday = Click::where('workspace_id', $workspace->id)
|
||||||
|
|
@ -20,6 +25,7 @@ class Index extends Component
|
||||||
->count();
|
->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
#[On('echo:workspace.{workspaceId}.analytics,click.recorded')]
|
#[On('echo:workspace.{workspaceId}.analytics,click.recorded')]
|
||||||
public function handleNewClick(array $data): void
|
public function handleNewClick(array $data): void
|
||||||
{
|
{
|
||||||
|
|
@ -28,8 +34,9 @@ class Index extends Component
|
||||||
$this->totalToday++;
|
$this->totalToday++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): \Illuminate\View\View
|
public function render(): View
|
||||||
{
|
{
|
||||||
|
/** @var Workspace $workspace */
|
||||||
$workspace = app('current_workspace');
|
$workspace = app('current_workspace');
|
||||||
|
|
||||||
return view('livewire.pages.analytics.index', [
|
return view('livewire.pages.analytics.index', [
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
namespace App\Livewire\Pages\Billing;
|
namespace App\Livewire\Pages\Billing;
|
||||||
|
|
||||||
|
use Illuminate\View\View;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
public function render(): \Illuminate\View\View
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('livewire.pages.coming-soon')
|
return view('livewire.pages.coming-soon')
|
||||||
->layout('layouts.nimuli-app', ['title' => 'Billing']);
|
->layout('layouts.nimuli-app', ['title' => 'Billing']);
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
namespace App\Livewire\Pages\Bio;
|
namespace App\Livewire\Pages\Bio;
|
||||||
|
|
||||||
|
use Illuminate\View\View;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
public function render(): \Illuminate\View\View
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('livewire.pages.coming-soon')
|
return view('livewire.pages.coming-soon')
|
||||||
->layout('layouts.nimuli-app', ['title' => 'Bio Pages']);
|
->layout('layouts.nimuli-app', ['title' => 'Bio Pages']);
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
namespace App\Livewire\Pages\Domains;
|
namespace App\Livewire\Pages\Domains;
|
||||||
|
|
||||||
|
use Illuminate\View\View;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
public function render(): \Illuminate\View\View
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('livewire.pages.coming-soon')
|
return view('livewire.pages.coming-soon')
|
||||||
->layout('layouts.nimuli-app', ['title' => 'Domains']);
|
->layout('layouts.nimuli-app', ['title' => 'Domains']);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Pages\Links;
|
||||||
|
|
||||||
use App\Domains\Link\Actions\DeleteLink;
|
use App\Domains\Link\Actions\DeleteLink;
|
||||||
use App\Domains\Link\Models\Link;
|
use App\Domains\Link\Models\Link;
|
||||||
|
use Illuminate\View\View;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ class Index extends Component
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
public string $statusFilter = '';
|
public string $statusFilter = '';
|
||||||
|
|
||||||
public function updatingSearch(): void
|
public function updatingSearch(): void
|
||||||
|
|
@ -36,17 +38,17 @@ class Index extends Component
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): \Illuminate\View\View
|
public function render(): View
|
||||||
{
|
{
|
||||||
$workspace = app('current_workspace');
|
$workspace = app('current_workspace');
|
||||||
|
|
||||||
$links = Link::where('workspace_id', $workspace->id)
|
$links = Link::where('workspace_id', $workspace->id)
|
||||||
->when($this->search, fn($q) => $q->where(function ($q) {
|
->when($this->search, fn ($q) => $q->where(function ($q) {
|
||||||
$q->where('title', 'like', "%{$this->search}%")
|
$q->where('title', 'like', "%{$this->search}%")
|
||||||
->orWhere('slug', 'like', "%{$this->search}%")
|
->orWhere('slug', 'like', "%{$this->search}%")
|
||||||
->orWhere('target_url', 'like', "%{$this->search}%");
|
->orWhere('target_url', 'like', "%{$this->search}%");
|
||||||
}))
|
}))
|
||||||
->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
|
->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate(20);
|
->paginate(20);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
namespace App\Livewire\Pages\QrCodes;
|
namespace App\Livewire\Pages\QrCodes;
|
||||||
|
|
||||||
|
use Illuminate\View\View;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
public function render(): \Illuminate\View\View
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('livewire.pages.coming-soon')
|
return view('livewire.pages.coming-soon')
|
||||||
->layout('layouts.nimuli-app', ['title' => 'QR Codes']);
|
->layout('layouts.nimuli-app', ['title' => 'QR Codes']);
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
namespace App\Livewire\Pages\Settings;
|
namespace App\Livewire\Pages\Settings;
|
||||||
|
|
||||||
|
use Illuminate\View\View;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
public function render(): \Illuminate\View\View
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('livewire.pages.coming-soon')
|
return view('livewire.pages.coming-soon')
|
||||||
->layout('layouts.nimuli-app', ['title' => 'Settings']);
|
->layout('layouts.nimuli-app', ['title' => 'Settings']);
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,21 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use App\Domains\Workspace\Models\WorkspaceMember;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use PragmaRX\Google2FA\Google2FA;
|
use PragmaRX\Google2FA\Google2FA;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||||
|
|
@ -24,7 +27,7 @@ class User extends Authenticatable
|
||||||
{
|
{
|
||||||
static::creating(function (self $user) {
|
static::creating(function (self $user) {
|
||||||
if (empty($user->ulid)) {
|
if (empty($user->ulid)) {
|
||||||
$user->ulid = \Illuminate\Support\Str::ulid();
|
$user->ulid = Str::ulid();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -53,24 +56,28 @@ class User extends Authenticatable
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Workspace, $this> */
|
||||||
public function workspaces(): HasMany
|
public function workspaces(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Domains\Workspace\Models\Workspace::class, 'owner_id');
|
return $this->hasMany(Workspace::class, 'owner_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Workspace, $this> */
|
||||||
public function defaultWorkspace(): BelongsTo
|
public function defaultWorkspace(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class, 'default_workspace_id');
|
return $this->belongsTo(Workspace::class, 'default_workspace_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WorkspaceMember, $this> */
|
||||||
public function workspaceMemberships(): HasMany
|
public function workspaceMemberships(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(\App\Domains\Workspace\Models\WorkspaceMember::class);
|
return $this->hasMany(WorkspaceMember::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
public function enableTwoFactor(string $secret): array
|
public function enableTwoFactor(string $secret): array
|
||||||
{
|
{
|
||||||
$recoveryCodes = collect(range(1, 8))->map(fn() => implode('-', [
|
$recoveryCodes = collect(range(1, 8))->map(fn () => implode('-', [
|
||||||
strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
|
strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
|
||||||
strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
|
strtoupper(substr(bin2hex(random_bytes(5)), 0, 5)),
|
||||||
]))->all();
|
]))->all();
|
||||||
|
|
@ -105,6 +112,7 @@ class User extends Authenticatable
|
||||||
|
|
||||||
$secret = decrypt($this->two_factor_secret);
|
$secret = decrypt($this->two_factor_secret);
|
||||||
$google2fa = new Google2FA;
|
$google2fa = new Google2FA;
|
||||||
|
|
||||||
return $google2fa->verifyKey($secret, $code) !== false;
|
return $google2fa->verifyKey($secret, $code) !== false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\LocaleFromUser;
|
||||||
|
use App\Http\Middleware\SecurityHeaders;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
@ -14,8 +16,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
\App\Http\Middleware\LocaleFromUser::class,
|
LocaleFromUser::class,
|
||||||
\App\Http\Middleware\SecurityHeaders::class,
|
SecurityHeaders::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->validateCsrfTokens(except: [
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\VoltServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
App\Providers\VoltServiceProvider::class,
|
VoltServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Spatie\LaravelData\Data;
|
||||||
|
use Spatie\LaravelSettings\SettingsCasts\DataCast;
|
||||||
|
use Spatie\LaravelSettings\SettingsCasts\DateTimeInterfaceCast;
|
||||||
|
use Spatie\LaravelSettings\SettingsCasts\DateTimeZoneCast;
|
||||||
|
use Spatie\LaravelSettings\SettingsRepositories\DatabaseSettingsRepository;
|
||||||
|
use Spatie\LaravelSettings\SettingsRepositories\RedisSettingsRepository;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -35,13 +42,13 @@ return [
|
||||||
*/
|
*/
|
||||||
'repositories' => [
|
'repositories' => [
|
||||||
'database' => [
|
'database' => [
|
||||||
'type' => Spatie\LaravelSettings\SettingsRepositories\DatabaseSettingsRepository::class,
|
'type' => DatabaseSettingsRepository::class,
|
||||||
'model' => null,
|
'model' => null,
|
||||||
'table' => null,
|
'table' => null,
|
||||||
'connection' => null,
|
'connection' => null,
|
||||||
],
|
],
|
||||||
'redis' => [
|
'redis' => [
|
||||||
'type' => Spatie\LaravelSettings\SettingsRepositories\RedisSettingsRepository::class,
|
'type' => RedisSettingsRepository::class,
|
||||||
'connection' => null,
|
'connection' => null,
|
||||||
'prefix' => null,
|
'prefix' => null,
|
||||||
],
|
],
|
||||||
|
|
@ -78,10 +85,10 @@ return [
|
||||||
* your settings class isn't a default PHP type.
|
* your settings class isn't a default PHP type.
|
||||||
*/
|
*/
|
||||||
'global_casts' => [
|
'global_casts' => [
|
||||||
DateTimeInterface::class => Spatie\LaravelSettings\SettingsCasts\DateTimeInterfaceCast::class,
|
DateTimeInterface::class => DateTimeInterfaceCast::class,
|
||||||
DateTimeZone::class => Spatie\LaravelSettings\SettingsCasts\DateTimeZoneCast::class,
|
DateTimeZone::class => DateTimeZoneCast::class,
|
||||||
// Spatie\DataTransferObject\DataTransferObject::class => Spatie\LaravelSettings\SettingsCasts\DtoCast::class,
|
// Spatie\DataTransferObject\DataTransferObject::class => Spatie\LaravelSettings\SettingsCasts\DtoCast::class,
|
||||||
Spatie\LaravelData\Data::class => Spatie\LaravelSettings\SettingsCasts\DataCast::class,
|
Data::class => DataCast::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Domains\Bio\Models\BioPage;
|
use App\Domains\Bio\Models\BioPage;
|
||||||
use App\Domains\Workspace\Models\Workspace;
|
use App\Domains\Workspace\Actions\CreateWorkspace;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -15,7 +15,7 @@ class BioPageFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
|
$workspace = (new CreateWorkspace)->handle($user, ['name' => 'Test']);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'workspace_id' => $workspace->id,
|
'workspace_id' => $workspace->id,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Domains\Domain\Models\Domain;
|
use App\Domains\Domain\Models\Domain;
|
||||||
use App\Domains\Workspace\Models\Workspace;
|
use App\Domains\Workspace\Actions\CreateWorkspace;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -15,7 +15,7 @@ class DomainFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
|
$workspace = (new CreateWorkspace)->handle($user, ['name' => 'Test']);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'workspace_id' => $workspace->id,
|
'workspace_id' => $workspace->id,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Domains\Webhook\Models\Webhook;
|
use App\Domains\Webhook\Models\Webhook;
|
||||||
use App\Domains\Workspace\Models\Workspace;
|
use App\Domains\Workspace\Actions\CreateWorkspace;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ class WebhookFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
|
$workspace = (new CreateWorkspace)->handle($user, ['name' => 'Test']);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'workspace_id' => $workspace->id,
|
'workspace_id' => $workspace->id,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
includes:
|
||||||
|
- vendor/larastan/larastan/extension.neon
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
paths:
|
||||||
|
- app
|
||||||
|
level: 6
|
||||||
|
ignoreErrors:
|
||||||
|
- '#Property App\\Domains\\QrCode\\Actions\\GenerateQrCode::\$renderer is never read, only written#'
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Analytics\Jobs\PruneRawClicksJob;
|
||||||
|
use App\Domains\Analytics\Jobs\RollupDailyJob;
|
||||||
|
use App\Domains\Analytics\Jobs\RollupHourlyJob;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
use App\Domains\Analytics\Jobs\RollupHourlyJob;
|
|
||||||
use App\Domains\Analytics\Jobs\RollupDailyJob;
|
|
||||||
use App\Domains\Analytics\Jobs\PruneRawClicksJob;
|
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\BioPageController;
|
||||||
|
use App\Http\Controllers\RedirectController;
|
||||||
|
use App\Http\Middleware\ResolveWorkspace;
|
||||||
|
use App\Livewire\Pages\Links\Index;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/health', function () {
|
Route::get('/health', function () {
|
||||||
|
|
@ -17,25 +21,25 @@ Route::view('profile', 'profile')
|
||||||
->name('profile');
|
->name('profile');
|
||||||
|
|
||||||
// Workspace-scoped routes — placed before slug catch-all
|
// Workspace-scoped routes — placed before slug catch-all
|
||||||
Route::middleware(['auth', 'verified', \App\Http\Middleware\ResolveWorkspace::class])
|
Route::middleware(['auth', 'verified', ResolveWorkspace::class])
|
||||||
->prefix('w/{workspace}')
|
->prefix('w/{workspace}')
|
||||||
->name('w.')
|
->name('w.')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
Route::get('/links', \App\Livewire\Pages\Links\Index::class)->name('links.index');
|
Route::get('/links', Index::class)->name('links.index');
|
||||||
Route::get('/qr', \App\Livewire\Pages\QrCodes\Index::class)->name('qr.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('/bio', App\Livewire\Pages\Bio\Index::class)->name('bio.index');
|
||||||
Route::get('/analytics', \App\Livewire\Pages\Analytics\Index::class)->name('analytics.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('/domains', App\Livewire\Pages\Domains\Index::class)->name('domains.index');
|
||||||
Route::get('/settings', \App\Livewire\Pages\Settings\Index::class)->name('settings.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');
|
Route::get('/billing', App\Livewire\Pages\Billing\Index::class)->name('billing.index');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bio page route — must be before the slug catch-all
|
// Bio page route — must be before the slug catch-all
|
||||||
Route::get('/bio/{slug}', [\App\Http\Controllers\BioPageController::class, 'show'])->name('bio.public');
|
Route::get('/bio/{slug}', [BioPageController::class, 'show'])->name('bio.public');
|
||||||
|
|
||||||
// Redirect service — handles slugs on main domain and custom domains
|
// Redirect service — handles slugs on main domain and custom domains
|
||||||
// Placed after all named routes so dashboard/profile etc. are not caught here
|
// Placed after all named routes so dashboard/profile etc. are not caught here
|
||||||
Route::get('/{slug}', \App\Http\Controllers\RedirectController::class)
|
Route::get('/{slug}', RedirectController::class)
|
||||||
->where('slug', '[a-zA-Z0-9_-]+')
|
->where('slug', '[a-zA-Z0-9_-]+')
|
||||||
->name('redirect');
|
->name('redirect');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Link\Models\Link;
|
use App\Domains\Link\Models\Link;
|
||||||
use App\Domains\Workspace\Models\Workspace;
|
use App\Domains\Workspace\Actions\CreateWorkspace;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
it('lists links for authenticated workspace', function () {
|
it('lists links for authenticated workspace', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
|
$workspace = (new CreateWorkspace)->handle($user, ['name' => 'Test']);
|
||||||
Link::factory()->count(3)->create(['workspace_id' => $workspace->id]);
|
Link::factory()->count(3)->create(['workspace_id' => $workspace->id]);
|
||||||
|
|
||||||
$token = $user->createToken('api-test')->plainTextToken;
|
$token = $user->createToken('api-test')->plainTextToken;
|
||||||
|
|
@ -20,7 +20,7 @@ it('lists links for authenticated workspace', function () {
|
||||||
|
|
||||||
it('creates link via API', function () {
|
it('creates link via API', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
|
$workspace = (new CreateWorkspace)->handle($user, ['name' => 'Test']);
|
||||||
$token = $user->createToken('api-test')->plainTextToken;
|
$token = $user->createToken('api-test')->plainTextToken;
|
||||||
|
|
||||||
$response = $this->withToken($token)
|
$response = $this->withToken($token)
|
||||||
|
|
@ -34,7 +34,7 @@ it('creates link via API', function () {
|
||||||
|
|
||||||
it('deletes a link via API', function () {
|
it('deletes a link via API', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
|
$workspace = (new CreateWorkspace)->handle($user, ['name' => 'Test']);
|
||||||
$link = Link::factory()->create(['workspace_id' => $workspace->id]);
|
$link = Link::factory()->create(['workspace_id' => $workspace->id]);
|
||||||
$token = $user->createToken('api-test')->plainTextToken;
|
$token = $user->createToken('api-test')->plainTextToken;
|
||||||
|
|
||||||
|
|
@ -48,7 +48,7 @@ it('deletes a link via API', function () {
|
||||||
it('rejects delete from non-member', function () {
|
it('rejects delete from non-member', function () {
|
||||||
$owner = User::factory()->create();
|
$owner = User::factory()->create();
|
||||||
$outsider = User::factory()->create();
|
$outsider = User::factory()->create();
|
||||||
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($owner, ['name' => 'Test']);
|
$workspace = (new CreateWorkspace)->handle($owner, ['name' => 'Test']);
|
||||||
$link = Link::factory()->create(['workspace_id' => $workspace->id]);
|
$link = Link::factory()->create(['workspace_id' => $workspace->id]);
|
||||||
$token = $outsider->createToken('api-test')->plainTextToken;
|
$token = $outsider->createToken('api-test')->plainTextToken;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,14 @@ it('throttles login after 5 failed attempts', function () {
|
||||||
|
|
||||||
// The throttle key LoginForm uses: Str::transliterate(Str::lower($email) . '|' . request()->ip())
|
// The throttle key LoginForm uses: Str::transliterate(Str::lower($email) . '|' . request()->ip())
|
||||||
// In the test environment request()->ip() returns '127.0.0.1'
|
// In the test environment request()->ip() returns '127.0.0.1'
|
||||||
$throttleKey = Str::transliterate(Str::lower($email) . '|127.0.0.1');
|
$throttleKey = Str::transliterate(Str::lower($email).'|127.0.0.1');
|
||||||
RateLimiter::clear($throttleKey);
|
RateLimiter::clear($throttleKey);
|
||||||
|
|
||||||
// Make 5 failed attempts through the real Volt component
|
// Make 5 failed attempts through the real Volt component
|
||||||
for ($i = 0; $i < 5; $i++) {
|
for ($i = 0; $i < 5; $i++) {
|
||||||
Volt::test('pages.auth.login')
|
Volt::test('pages.auth.login')
|
||||||
->set('form.email', $email)
|
->set('form.email', $email)
|
||||||
->set('form.password', 'wrong' . $i)
|
->set('form.password', 'wrong'.$i)
|
||||||
->call('login');
|
->call('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ it('handles subscription_created webhook', function () {
|
||||||
'items' => ['data' => [['price' => ['id' => 'price_pro']]]],
|
'items' => ['data' => [['price' => ['id' => 'price_pro']]]],
|
||||||
'current_period_start' => now()->timestamp,
|
'current_period_start' => now()->timestamp,
|
||||||
'current_period_end' => now()->addMonth()->timestamp,
|
'current_period_end' => now()->addMonth()->timestamp,
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$response = $this->postJson('/stripe/webhook', $payload, [
|
$response = $this->postJson('/stripe/webhook', $payload, [
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
use App\Domains\Link\Services\LinkCacheService;
|
use App\Domains\Link\Services\LinkCacheService;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
uses(Tests\TestCase::class);
|
uses(TestCase::class);
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
// Clean up test keys
|
// Clean up test keys
|
||||||
|
|
@ -31,5 +32,5 @@ it('stores link data in redis hash', function () {
|
||||||
|
|
||||||
it('returns null for unknown slug', function () {
|
it('returns null for unknown slug', function () {
|
||||||
$service = new LinkCacheService;
|
$service = new LinkCacheService;
|
||||||
expect($service->get('nonexistent-' . uniqid()))->toBeNull();
|
expect($service->get('nonexistent-'.uniqid()))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Routing\Services\SmartRoutingEngine;
|
use App\Domains\Routing\Services\SmartRoutingEngine;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
uses(Tests\TestCase::class);
|
uses(TestCase::class);
|
||||||
|
|
||||||
it('returns fallback when no rules match', function () {
|
it('returns fallback when no rules match', function () {
|
||||||
$engine = new SmartRoutingEngine;
|
$engine = new SmartRoutingEngine;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Subscription\Services\PlanLimitsService;
|
|
||||||
use App\Domains\Subscription\Models\Plan;
|
|
||||||
use App\Domains\Workspace\Models\Workspace;
|
|
||||||
use App\Domains\Link\Models\Link;
|
use App\Domains\Link\Models\Link;
|
||||||
|
use App\Domains\Subscription\Models\Plan;
|
||||||
|
use App\Domains\Subscription\Services\PlanLimitsService;
|
||||||
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
uses(Tests\TestCase::class);
|
uses(TestCase::class);
|
||||||
|
|
||||||
it('allows link creation when under limit', function () {
|
it('allows link creation when under limit', function () {
|
||||||
$plan = Plan::factory()->create(['link_limit' => 10]);
|
$plan = Plan::factory()->create(['link_limit' => 10]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue