Initial commit: Pireon Admin — headless admin panel and public API

Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\ApplicationKind;
use App\Enums\ApplicationStatus;
use App\Models\Application;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Application>
*/
class ApplicationFactory extends Factory
{
protected $model = Application::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'call_id' => null,
'call_title' => null,
'kind' => ApplicationKind::Contribute,
'status' => ApplicationStatus::New,
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'organisation' => null,
'message' => fake()->paragraph(),
'hours_per_week' => fake()->randomElement([2, 3, 4, 6, null]),
'links' => ['github' => 'https://github.com/'.fake()->userName()],
'locale' => (string) config('locales.default'),
'ip_hash' => Application::hashIp(fake()->ipv4()),
];
}
public function forCall(int $callId, string $title = 'Backend'): static
{
return $this->state(fn () => [
'call_id' => $callId,
'call_title' => $title,
]);
}
public function request(): static
{
return $this->state(fn () => [
'kind' => ApplicationKind::Request,
'organisation' => fake()->company(),
'hours_per_week' => null,
'links' => null,
]);
}
public function handled(ApplicationStatus $status = ApplicationStatus::Contacted): static
{
return $this->state(fn () => [
'status' => $status,
'handled_at' => now(),
]);
}
public function spam(): static
{
return $this->state(fn () => ['status' => ApplicationStatus::Spam]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\CallStatus;
use App\Models\Call;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Call>
*/
class CallFactory extends Factory
{
protected $model = Call::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$role = fake()->randomElement([
'Frontend', 'Backend', 'Diseño', 'DevOps', 'QA', 'Documentación',
]);
$title = "{$role} · ".rtrim(fake()->unique()->catchPhrase(), '.');
return [
'title' => $this->eachLocale(fn (string $l) => "{$title} ({$l})"),
'slug' => $this->eachLocale(fn (string $l) => Str::slug("{$title}-{$l}")),
'description' => $this->eachLocale(fn () => fake()->paragraph()),
'what_you_get' => $this->eachLocale(fn () => fake()->sentence(12)),
'status' => CallStatus::Open,
// The honest ask: a few hours a week, for a bounded stretch.
'hours_per_week' => fake()->randomElement([2, 3, 4, 5, 6, 8]),
'duration_weeks' => fake()->randomElement([4, 8, 12, 24]),
'seniority' => fake()->randomElement(['junior', 'mid', 'any']),
'opens_at' => now()->subWeeks(2),
'closes_at' => fake()->optional(0.5)->dateTimeBetween('+2 weeks', '+3 months'),
];
}
public function closed(): static
{
return $this->state(fn () => ['status' => CallStatus::Closed]);
}
public function filled(): static
{
return $this->state(fn () => ['status' => CallStatus::Filled]);
}
/** @return array<string, string> */
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => $value($l)])
->all();
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Category>
*/
class CategoryFactory extends Factory
{
protected $model = Category::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$name = $this->faker->unique()->words(2, true);
return [
// Every supported locale gets a value so factory-made records
// exercise the translation paths rather than the fallback.
'name' => $this->eachLocale(fn (string $locale) => ucfirst($name)." ({$locale})"),
'slug' => $this->eachLocale(fn (string $locale) => Str::slug($name."-{$locale}")),
'description' => $this->eachLocale(fn () => $this->faker->sentence()),
'color' => $this->faker->hexColor(),
'position' => $this->faker->numberBetween(0, 20),
'is_visible' => true,
];
}
/**
* @param callable(string): string $value
* @return array<string, string>
*/
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $locale) => [$locale => $value($locale)])
->all();
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Client;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Client>
*/
class ClientFactory extends Factory
{
protected $model = Client::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->company();
return [
'name' => $name,
'slug' => Str::slug($name),
'sector' => $this->eachLocale(fn () => fake()->randomElement([
'ONG', 'Educación', 'Cultura', 'Salud', 'Medio ambiente',
])),
'logo' => null,
'url' => fake()->optional(0.7)->url(),
'is_public' => true,
'position' => 0,
];
}
public function private(): static
{
return $this->state(fn () => ['is_public' => false]);
}
/** @return array<string, string> */
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => $value($l)])
->all();
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ImageAttachment;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ImageAttachment>
*/
class ImageAttachmentFactory extends Factory
{
protected $model = ImageAttachment::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'collection' => 'gallery',
'path' => 'projects/'.fake()->unique()->uuid().'.jpg',
'alt' => $this->eachLocale(fn () => fake()->sentence(6)),
'caption' => null,
'position' => 0,
];
}
/** @return array<string, string> */
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => $value($l)])
->all();
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Media;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Media>
*/
class MediaFactory extends Factory
{
protected $model = Media::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'disk' => (string) config('media.disk', 'public'),
'path' => 'projects/'.fake()->unique()->uuid().'.jpg',
'mime' => 'image/jpeg',
'size' => fake()->numberBetween(50_000, 900_000),
'width' => null,
'height' => null,
'variants' => null,
'placeholder' => null,
'processed_at' => null,
];
}
/**
* An image the processor has already been through.
*/
public function processed(int $width = 1600, int $height = 900): static
{
return $this->state(fn (array $attributes) => [
'width' => $width,
'height' => $height,
'placeholder' => 'data:image/webp;base64,UklGRhoAAABXRUJQ',
'variants' => collect([400, 800, 1200])
->filter(fn (int $w) => $w < $width)
->map(fn (int $w) => [
'format' => 'webp',
'width' => $w,
'path' => str_replace('.jpg', "-{$w}.webp", $attributes['path']),
'size' => $w * 100,
])
->values()
->all(),
'processed_at' => now(),
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Member;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Member>
*/
class MemberFactory extends Factory
{
protected $model = Member::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->name();
$handle = Str::slug($name).fake()->unique()->numberBetween(1, 9999);
return [
'user_id' => null,
'name' => $name,
'slug' => $handle,
'headline' => $this->eachLocale(fn (string $l) => fake()->jobTitle()." ({$l})"),
'bio' => $this->eachLocale(fn () => fake()->paragraph()),
'github_username' => $handle,
'links' => [
'github' => "https://github.com/{$handle}",
'website' => fake()->optional()->url(),
],
'is_public' => true,
'is_founder' => false,
'joined_at' => fake()->dateTimeBetween('-4 years'),
'position' => fake()->numberBetween(0, 20),
];
}
public function founder(): static
{
return $this->state(fn () => ['is_founder' => true]);
}
/**
* Someone who has moved on. Their work stays on the site.
*/
public function past(): static
{
return $this->state(fn () => ['left_at' => fake()->dateTimeBetween('-1 year')]);
}
public function hidden(): static
{
return $this->state(fn () => ['is_public' => false]);
}
/** @return array<string, string> */
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => $value($l)])
->all();
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\MissingUrlStatus;
use App\Models\MissingUrl;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<MissingUrl>
*/
class MissingUrlFactory extends Factory
{
protected $model = MissingUrl::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'path' => '/'.fake()->unique()->slug(),
'locale' => 'es',
'hits' => fake()->numberBetween(1, 40),
'referrer' => null,
'status' => MissingUrlStatus::Pending,
'first_seen_at' => now()->subDays(3),
'last_seen_at' => now(),
];
}
/**
* Linked from our own site: a page we publish points at a dead address.
*/
public function internal(): static
{
return $this->state(fn () => [
'referrer' => rtrim((string) config('site.url'), '/').'/'.fake()->slug(),
]);
}
public function ignored(): static
{
return $this->state(fn () => ['status' => MissingUrlStatus::Ignored]);
}
public function resolved(): static
{
return $this->state(fn () => [
'status' => MissingUrlStatus::Resolved,
'resolved_at' => now(),
]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\PageStatus;
use App\Models\Category;
use App\Models\Page;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Page>
*/
class PageFactory extends Factory
{
protected $model = Page::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$title = $this->faker->unique()->sentence(4);
$status = $this->faker->randomElement(PageStatus::cases());
return [
'title' => $this->eachLocale(fn (string $locale) => rtrim($title, '.')." ({$locale})"),
'slug' => $this->eachLocale(fn (string $locale) => Str::slug($title."-{$locale}")),
'excerpt' => $this->eachLocale(fn () => $this->faker->sentence(12)),
'body' => $this->eachLocale(fn () => '<p>'.implode('</p><p>', $this->faker->paragraphs(3)).'</p>'),
'meta_title' => $this->eachLocale(fn () => Str::limit($title, 55, '')),
'meta_description' => $this->eachLocale(fn () => $this->faker->sentence(14)),
'category_id' => Category::factory(),
'author_id' => User::factory(),
'status' => $status,
'is_featured' => $this->faker->boolean(20),
'published_at' => $status === PageStatus::Published
? $this->faker->dateTimeBetween('-6 months')
: null,
];
}
public function published(): static
{
return $this->state(fn () => [
'status' => PageStatus::Published,
'published_at' => now()->subDays(fake()->numberBetween(0, 30)),
]);
}
public function draft(): static
{
return $this->state(fn () => [
'status' => PageStatus::Draft,
'published_at' => null,
]);
}
/**
* A page translated only into the default locale — the state the
* "missing translation" filters and the dashboard stat exist to surface.
*/
public function untranslated(): static
{
return $this->state(function (array $attributes): array {
$default = (string) config('locales.default');
return [
'title' => [$default => $attributes['title'][$default]],
'slug' => [$default => $attributes['slug'][$default]],
'excerpt' => [$default => $attributes['excerpt'][$default]],
'body' => [$default => $attributes['body'][$default]],
];
});
}
/**
* @param callable(string): string $value
* @return array<string, string>
*/
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $locale) => [$locale => $value($locale)])
->all();
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\ProjectStatus;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Project>
*/
class ProjectFactory extends Factory
{
protected $model = Project::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$title = rtrim(fake()->unique()->catchPhrase(), '.');
$status = fake()->randomElement(ProjectStatus::cases());
return [
'title' => $this->eachLocale(fn (string $l) => "{$title} ({$l})"),
'slug' => $this->eachLocale(fn (string $l) => Str::slug("{$title}-{$l}")),
'tagline' => $this->eachLocale(fn () => fake()->sentence(8)),
'description' => $this->eachLocale(fn () => '<p>'.implode('</p><p>', fake()->paragraphs(3)).'</p>'),
'decisions' => $this->eachLocale(fn () => '<p>'.fake()->paragraph().'</p>'),
'postmortem' => null,
'meta_title' => $this->eachLocale(fn () => Str::limit($title, 55, '')),
'meta_description' => $this->eachLocale(fn () => fake()->sentence(14)),
'status' => $status,
'demo_url' => fake()->optional()->url(),
'is_featured' => fake()->boolean(20),
'needs_help' => fake()->boolean(30),
'started_at' => fake()->dateTimeBetween('-3 years'),
'published_at' => now()->subDays(fake()->numberBetween(1, 400)),
'position' => fake()->numberBetween(0, 20),
];
}
public function published(): static
{
return $this->state(fn () => ['published_at' => now()->subDay()]);
}
public function draft(): static
{
return $this->state(fn () => ['published_at' => null]);
}
public function active(): static
{
return $this->state(fn () => ['status' => ProjectStatus::Active]);
}
public function needingHelp(): static
{
return $this->state(fn () => [
'needs_help' => true,
'status' => ProjectStatus::Active,
]);
}
/**
* A finished project with the write-up that makes it worth reading — the
* kind of content a commercial consultancy cannot publish.
*/
public function withPostmortem(): static
{
return $this->state(fn () => [
'status' => ProjectStatus::Archived,
'ended_at' => fake()->dateTimeBetween('-1 year'),
'postmortem' => $this->eachLocale(
fn () => '<p>'.implode('</p><p>', fake()->paragraphs(2)).'</p>',
),
]);
}
/** @return array<string, string> */
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => $value($l)])
->all();
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Redirect;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Redirect>
*/
class RedirectFactory extends Factory
{
protected $model = Redirect::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'from' => '/'.fake()->unique()->slug(),
'to' => '/'.fake()->slug(),
'status' => 301,
'locale' => null,
'is_enabled' => true,
'hits' => 0,
];
}
public function disabled(): static
{
return $this->state(fn () => ['is_enabled' => false]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\GitProvider;
use App\Models\Repository;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Repository>
*/
class RepositoryFactory extends Factory
{
protected $model = Repository::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$owner = 'colectivo';
$name = Str::slug(fake()->unique()->words(2, true));
return [
'provider' => GitProvider::Gitea,
'full_name' => "{$owner}/{$name}",
'is_primary' => true,
'show_publicly' => true,
'html_url' => "https://git.pireon.test/{$owner}/{$name}",
'description' => fake()->sentence(),
'default_branch' => 'main',
'primary_language' => fake()->randomElement(['PHP', 'TypeScript', 'Rust', 'Go', 'Python']),
'license' => fake()->randomElement(['MIT', 'AGPL-3.0', 'Apache-2.0']),
'topics' => ['open-source', 'community'],
'languages' => ['PHP' => 120000, 'Blade' => 30000],
'stars' => fake()->numberBetween(0, 400),
'forks' => fake()->numberBetween(0, 40),
'watchers' => fake()->numberBetween(0, 30),
'open_issues_count' => fake()->numberBetween(0, 25),
'contributors_count' => fake()->numberBetween(1, 12),
'is_archived' => false,
'is_fork' => false,
'pushed_at' => fake()->dateTimeBetween('-2 months'),
'repo_created_at' => fake()->dateTimeBetween('-4 years', '-1 year'),
'last_synced_at' => now(),
];
}
/**
* Nothing pushed for a long time — what the public site uses to avoid
* advertising dead work as live.
*/
public function dormant(): static
{
return $this->state(fn () => [
'pushed_at' => now()->subYears(2),
]);
}
public function failing(): static
{
return $this->state(fn () => [
'sync_error' => 'The repository [colectivo/gone] does not exist.',
'sync_failures' => 5,
]);
}
public function hidden(): static
{
return $this->state(fn () => ['show_publicly' => false]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Service;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Service>
*/
class ServiceFactory extends Factory
{
protected $model = Service::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$title = fake()->unique()->randomElement([
'Desarrollo web', 'Acompañamiento técnico', 'Formación',
'Auditoría de accesibilidad', 'Infraestructura', 'Diseño de producto',
]).' '.fake()->unique()->numberBetween(1, 999);
return [
'title' => $this->eachLocale(fn (string $l) => "{$title} ({$l})"),
'slug' => $this->eachLocale(fn (string $l) => Str::slug("{$title}-{$l}")),
'tagline' => $this->eachLocale(fn () => fake()->sentence(8)),
'description' => $this->eachLocale(fn () => fake()->paragraph()),
'icon' => 'heroicon-o-code-bracket',
'is_published' => true,
'is_featured' => false,
'position' => 0,
];
}
public function draft(): static
{
return $this->state(fn () => ['is_published' => false]);
}
public function featured(): static
{
return $this->state(fn () => ['is_featured' => true]);
}
/** @return array<string, string> */
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => $value($l)])
->all();
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Talk;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Talk>
*/
class TalkFactory extends Factory
{
protected $model = Talk::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'title' => rtrim(fake()->unique()->catchPhrase(), '.'),
'language' => (string) config('locales.default'),
'summary' => $this->eachLocale(fn () => fake()->sentence(14)),
'event' => fake()->randomElement([
'Codemotion', 'T3chFest', 'JSDay', 'PHPBarcelona', 'Meetup Laravel',
]),
'city' => fake()->randomElement(['Madrid', 'Barcelona', 'Valencia', 'Sevilla']),
'held_at' => fake()->dateTimeBetween('-3 years'),
'video_url' => fake()->optional(0.6)->url(),
'slides_url' => fake()->optional(0.4)->url(),
'is_public' => true,
];
}
/** @return array<string, string> */
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => $value($l)])
->all();
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\RadarRing;
use App\Enums\TechnologyCategory;
use App\Models\Technology;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Technology>
*/
class TechnologyFactory extends Factory
{
protected $model = Technology::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->randomElement([
'Laravel', 'Astro', 'PHP', 'TypeScript', 'Rust', 'Go', 'PostgreSQL',
'Redis', 'Docker', 'Tailwind CSS', 'Vue', 'Svelte', 'Python',
'Kubernetes', 'Terraform', 'Elasticsearch', 'RabbitMQ', 'Vite',
]).' '.fake()->unique()->numberBetween(1, 9999);
return [
'name' => $name,
'slug' => Str::slug($name),
'description' => $this->eachLocale(fn () => fake()->sentence()),
'radar_note' => $this->eachLocale(fn () => fake()->sentence(10)),
'category' => fake()->randomElement(TechnologyCategory::cases()),
'radar_ring' => fake()->randomElement(RadarRing::cases()),
'color' => fake()->hexColor(),
'position' => fake()->numberBetween(0, 30),
];
}
public function offRadar(): static
{
return $this->state(fn () => ['radar_ring' => null]);
}
public function ring(RadarRing $ring): static
{
return $this->state(fn () => ['radar_ring' => $ring]);
}
/** @return array<string, string> */
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => $value($l)])
->all();
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Testimonial;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Testimonial>
*/
class TestimonialFactory extends Factory
{
protected $model = Testimonial::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'quote' => $this->eachLocale(fn () => fake()->sentence(20)),
'original_locale' => (string) config('locales.default'),
'author_name' => fake()->name(),
'author_role' => $this->eachLocale(fn () => fake()->jobTitle()),
'avatar' => null,
'is_public' => true,
'position' => 0,
'given_at' => fake()->optional(0.7)->dateTimeBetween('-2 years'),
];
}
public function private(): static
{
return $this->state(fn () => ['is_public' => false]);
}
/** @return array<string, string> */
protected function eachLocale(callable $value): array
{
return collect(array_keys(config('locales.supported')))
->mapWithKeys(fn (string $l) => [$l => $value($l)])
->all();
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* Default state mirrors what the OAuth callback produces: an identity
* copied from the server, with no local password.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'oauth_id' => fake()->unique()->numberBetween(1000, 999999),
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => null,
'locale' => fake()->randomElement(array_keys(config('locales.supported'))),
'is_active' => true,
'remote_roles' => ['user'],
'last_login_at' => now(),
'remember_token' => Str::random(10),
];
}
public function inactive(): static
{
return $this->state(fn () => ['is_active' => false]);
}
/**
* A user who has never signed in through the identity server, used to
* check that the callback adopts pre-existing local rows by email.
*/
public function unlinked(): static
{
return $this->state(fn () => [
'oauth_id' => null,
'last_login_at' => null,
]);
}
public function withRole(string $role): static
{
return $this->afterCreating(fn (User $user) => $user->assignRole($role));
}
}