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

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

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));
}
}

View File

@@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
// Identity mirrored from the Pireon OAuth2 server. `oauth_id` is the
// remote primary key and the only stable join key between the two
// systems — emails can be changed upstream.
$table->unsignedBigInteger('oauth_id')->nullable()->unique();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
// Nullable: users authenticate against the identity server, so most
// accounts never hold a local password hash.
$table->string('password')->nullable();
$table->string('avatar_url')->nullable();
$table->string('locale', 5)->nullable();
$table->boolean('is_active')->default(true);
// Roles reported by the identity server. Stored for reference and
// support only — they never grant anything here, because this app
// resolves authorisation from its own spatie roles/permissions.
$table->jsonb('remote_roles')->nullable();
$table->timestamp('last_login_at')->nullable();
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,134 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), Exception::class, 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // permission id
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::drop($tableNames['role_has_permissions']);
Schema::drop($tableNames['model_has_roles']);
Schema::drop($tableNames['model_has_permissions']);
Schema::drop($tableNames['roles']);
Schema::drop($tableNames['permissions']);
}
};

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
/*
* Translatable columns hold a JSON map of locale => value, e.g.
* {"es": "Servicios", "en": "Services", "ca": "Serveis"}.
* spatie/laravel-translatable casts these transparently.
*
* `jsonb` rather than `json`: Postgres cannot compare a `json`
* value for equality, so any `select distinct` touching one fails
* outright — and Filament's relationship selects emit exactly
* that. On MySQL it is still `json` and on SQLite still `text`,
* so nothing else changes.
*/
$table->jsonb('name');
$table->jsonb('slug');
$table->jsonb('description')->nullable();
$table->string('color', 7)->nullable();
$table->unsignedInteger('position')->default(0);
$table->boolean('is_visible')->default(true);
$table->timestamps();
$table->softDeletes();
$table->index('position');
});
Schema::create('pages', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->nullable()
->constrained()->nullOnDelete();
$table->foreignId('author_id')->nullable()
->constrained('users')->nullOnDelete();
$table->jsonb('title');
// Slugs live inside JSON, so uniqueness cannot be a DB constraint.
// It is enforced per locale by App\Rules\UniqueTranslatedSlug.
$table->jsonb('slug');
$table->jsonb('excerpt')->nullable();
$table->jsonb('body')->nullable();
$table->jsonb('meta_title')->nullable();
$table->jsonb('meta_description')->nullable();
$table->string('status', 20)->default('draft');
$table->string('featured_image')->nullable();
$table->boolean('is_featured')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'published_at']);
});
}
public function down(): void
{
Schema::dropIfExists('pages');
Schema::dropIfExists('categories');
}
};

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The content model of the collective: what we build (projects), who we are
* (members), what we use (technologies) and what we need (calls for help).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('technologies', function (Blueprint $table) {
$table->id();
// Proper nouns — "Laravel", "Rust" — so the name is NOT translatable.
$table->string('name');
$table->string('slug')->unique();
// Only the prose about a technology is translated.
$table->jsonb('description')->nullable();
$table->jsonb('radar_note')->nullable();
$table->string('category', 30)->default('tool');
// Tech radar placement. Null keeps a technology off the radar while
// still letting projects and members reference it.
$table->string('radar_ring', 20)->nullable();
$table->string('color', 7)->nullable();
$table->string('icon')->nullable();
$table->string('website')->nullable();
$table->unsignedInteger('position')->default(0);
$table->timestamps();
$table->index('radar_ring');
});
Schema::create('members', function (Blueprint $table) {
$table->id();
// Optional: most members never need panel access, so a member is
// not a user. When they do sign in, this links the two.
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->jsonb('headline')->nullable();
$table->jsonb('bio')->nullable();
$table->string('avatar')->nullable();
$table->string('email')->nullable();
// Drives the commit-attribution side of the git integration.
$table->string('github_username')->nullable()->index();
$table->jsonb('links')->nullable();
$table->boolean('is_public')->default(true);
$table->boolean('is_founder')->default(false);
$table->date('joined_at')->nullable();
$table->date('left_at')->nullable();
$table->unsignedInteger('position')->default(0);
$table->timestamps();
$table->softDeletes();
});
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('lead_member_id')->nullable()
->constrained('members')->nullOnDelete();
$table->jsonb('title');
$table->jsonb('slug');
$table->jsonb('tagline')->nullable();
$table->jsonb('description')->nullable();
// The differentiator: what we decided and why, and what went wrong.
// A real consultancy cannot publish these; a hobby collective can.
$table->jsonb('decisions')->nullable();
$table->jsonb('postmortem')->nullable();
$table->jsonb('meta_title')->nullable();
$table->jsonb('meta_description')->nullable();
$table->string('status', 20)->default('idea');
// Who the work is for — an NGO, an association, or ourselves.
$table->string('beneficiary')->nullable();
$table->string('beneficiary_url')->nullable();
$table->string('demo_url')->nullable();
$table->string('cover_image')->nullable();
$table->boolean('is_featured')->default(false);
// Surfaces the project in the "we need help" listings even when it
// has no specific open call yet.
$table->boolean('needs_help')->default(false);
$table->date('started_at')->nullable();
$table->date('ended_at')->nullable();
$table->timestamp('published_at')->nullable();
$table->unsignedInteger('position')->default(0);
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'published_at']);
});
Schema::create('calls', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->nullable()
->constrained()->cascadeOnDelete();
$table->jsonb('title');
$table->jsonb('slug');
$table->jsonb('description')->nullable();
// The honest exchange for unpaid work: what a contributor gets out
// of it. Stated openly rather than left implied.
$table->jsonb('what_you_get')->nullable();
$table->string('status', 20)->default('open');
// Time is the scarce resource in a hobby collective, not money.
// "~4h/week for 2 months" lets someone decide in ten seconds.
$table->unsignedTinyInteger('hours_per_week')->nullable();
$table->unsignedSmallInteger('duration_weeks')->nullable();
$table->string('seniority', 20)->nullable();
$table->date('opens_at')->nullable();
$table->date('closes_at')->nullable();
$table->timestamps();
$table->index(['status', 'closes_at']);
});
Schema::create('project_technology', function (Blueprint $table) {
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('technology_id')->constrained()->cascadeOnDelete();
$table->primary(['project_id', 'technology_id']);
});
Schema::create('member_technology', function (Blueprint $table) {
$table->foreignId('member_id')->constrained()->cascadeOnDelete();
$table->foreignId('technology_id')->constrained()->cascadeOnDelete();
$table->primary(['member_id', 'technology_id']);
});
Schema::create('call_technology', function (Blueprint $table) {
$table->foreignId('call_id')->constrained()->cascadeOnDelete();
$table->foreignId('technology_id')->constrained()->cascadeOnDelete();
$table->primary(['call_id', 'technology_id']);
});
Schema::create('member_project', function (Blueprint $table) {
$table->foreignId('member_id')->constrained()->cascadeOnDelete();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->string('role')->nullable();
$table->primary(['member_id', 'project_id']);
});
}
public function down(): void
{
Schema::dropIfExists('member_project');
Schema::dropIfExists('call_technology');
Schema::dropIfExists('member_technology');
Schema::dropIfExists('project_technology');
Schema::dropIfExists('calls');
Schema::dropIfExists('projects');
Schema::dropIfExists('members');
Schema::dropIfExists('technologies');
}
};

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Git repositories mirrored from the hosting provider.
*
* Everything in `repositories` except the identifying columns is a cache of
* what the provider reported, refreshed by App\Jobs\SyncRepository. Nothing
* here is edited by hand, which is the point: the site stays alive without
* anyone maintaining it.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('repositories', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->nullable()
->constrained()->nullOnDelete();
// gitea | github | gitlab — see App\Enums\GitProvider.
$table->string('provider', 20)->default('gitea');
// "owner/repo", the only identifier every provider agrees on.
$table->string('full_name');
$table->boolean('is_primary')->default(false);
$table->boolean('show_publicly')->default(true);
// ── Synced from the provider ────────────────────────────────────
$table->string('html_url')->nullable();
$table->text('description')->nullable();
$table->string('homepage')->nullable();
$table->string('default_branch')->nullable();
$table->string('primary_language')->nullable();
$table->string('license')->nullable();
$table->jsonb('topics')->nullable();
$table->jsonb('languages')->nullable();
$table->unsignedInteger('stars')->default(0);
$table->unsignedInteger('forks')->default(0);
$table->unsignedInteger('watchers')->default(0);
$table->unsignedInteger('open_issues_count')->default(0);
$table->unsignedInteger('contributors_count')->default(0);
$table->unsignedInteger('commits_last_year')->default(0);
$table->boolean('is_archived')->default(false);
$table->boolean('is_fork')->default(false);
$table->timestamp('pushed_at')->nullable();
$table->timestamp('repo_created_at')->nullable();
// Sync bookkeeping. `sync_error` keeps the last failure visible in
// the panel instead of burying it in the log.
$table->timestamp('last_synced_at')->nullable();
$table->text('sync_error')->nullable();
$table->unsignedSmallInteger('sync_failures')->default(0);
$table->timestamps();
$table->unique(['provider', 'full_name']);
$table->index('project_id');
});
Schema::create('repository_issues', function (Blueprint $table) {
$table->id();
$table->foreignId('repository_id')->constrained()->cascadeOnDelete();
// The provider's own issue number, unique within a repository.
$table->unsignedInteger('number');
$table->string('title');
$table->string('html_url');
$table->jsonb('labels')->nullable();
// What turns a browsing visitor into a contributor in one click.
$table->boolean('is_good_first_issue')->default(false);
$table->boolean('is_help_wanted')->default(false);
$table->string('author')->nullable();
$table->string('assignee')->nullable();
$table->unsignedInteger('comments')->default(0);
$table->timestamp('issue_created_at')->nullable();
$table->timestamp('issue_updated_at')->nullable();
$table->timestamps();
$table->unique(['repository_id', 'number']);
$table->index(['is_good_first_issue', 'is_help_wanted']);
});
}
public function down(): void
{
Schema::dropIfExists('repository_issues');
Schema::dropIfExists('repositories');
}
};

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Runtime settings the panel can change without a deploy.
*
* Kept in the database rather than in .env precisely because that is the
* point: putting the public site into maintenance has to be something an
* administrator can do from the panel at 2am, not something that needs a
* release.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->string('key')->primary();
// JSON so a setting can be a flag, a date, or a translated map
// without needing a column per shape.
$table->jsonb('value')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('settings');
}
};

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The inbox: everything the public site sends back.
*
* Until now the public API was read-only, so a call for contributors ended at
* "here is what we need" with nowhere for the reader to answer. This is the
* other half of the conversion point.
*
* Two kinds share one table because the handling is identical — somebody wrote
* in, somebody has to reply — and only the intent differs: offering time, or
* asking for it.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('applications', function (Blueprint $table) {
$table->id();
// Null for a general enquiry, and null again if the call is later
// deleted: a person who wrote in must not disappear because the
// opening they answered was tidied up.
$table->foreignId('call_id')->nullable()
->constrained()->nullOnDelete();
// Snapshot of what they answered, so the record still reads
// correctly once the call is gone or its title is rewritten.
$table->string('call_title')->nullable();
$table->string('kind', 20)->default('contribute');
$table->string('status', 20)->default('new');
$table->string('name');
$table->string('email');
$table->string('organisation')->nullable();
$table->text('message');
// Mirrors the call's own ask, so the two numbers can be compared
// at a glance instead of hunting through the message.
$table->unsignedTinyInteger('hours_per_week')->nullable();
$table->jsonb('links')->nullable();
// The language they wrote in — reply in the same one.
$table->string('locale', 5)->default('es');
// Hashed, never raw: enough to recognise a flood coming from one
// source, without this table becoming a log of visitors' IPs.
$table->string('ip_hash', 64)->nullable();
// Internal side, invisible to the sender.
$table->text('notes')->nullable();
$table->foreignId('handled_by')->nullable()
->constrained('users')->nullOnDelete();
$table->timestamp('handled_at')->nullable();
$table->timestamps();
$table->softDeletes();
// The inbox is read newest-unhandled-first, and deduplication
// looks up by email.
$table->index(['status', 'created_at']);
$table->index('email');
});
}
public function down(): void
{
Schema::dropIfExists('applications');
}
};

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Backs Filament's notification bell.
*
* An inbox nobody is told about is an inbox nobody reads, and the person who
* runs a call is not the person refreshing the panel all day.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
/*
* `jsonb`, not the `text` Laravel's own stub uses: Filament's bell
* queries this column by JSON path — `data->>'format'` — and
* Postgres has no such operator for text. SQLite never noticed.
*/
$table->jsonb('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* What is actually known about an uploaded image.
*
* Until now an image was a string — a path, and nothing else. For a frontend
* rendering server-side that is not enough to render one properly: without
* width and height the page reflows as images arrive, and without derived
* versions every visitor downloads a 2000px original to fill a 400px slot.
*
* This table is *derived*: everything in it can be rebuilt from the files on
* disk with `php artisan media:process --all`. Nobody edits it by hand, which
* is why the human part of an image — its alt text, which depends on where the
* image is used — lives on the record using it instead.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('media', function (Blueprint $table) {
$table->id();
// Identity is (disk, path): the file is the record. Two models
// pointing at the same upload share one row and one set of
// derived versions.
$table->string('disk', 40);
$table->string('path', 500);
$table->string('mime', 100)->nullable();
$table->unsignedBigInteger('size')->nullable();
// The pair that stops the page reflowing as images load.
$table->unsignedInteger('width')->nullable();
$table->unsignedInteger('height')->nullable();
// Tiny blurred data URI, inlined by the frontend while the real
// image loads.
$table->text('placeholder')->nullable();
// [{ "format": "webp", "width": 800, "path": "...", "size": 1234 }]
$table->jsonb('variants')->nullable();
// Null means "not processed yet". The API serves the image anyway,
// just without the extras, so an unprocessed upload degrades into
// a plain <img> rather than a hole in the page.
$table->timestamp('processed_at')->nullable();
$table->text('error')->nullable();
$table->timestamps();
$table->unique(['disk', 'path']);
$table->index('processed_at');
});
// Alt text belongs to the record showing the image, not to the file:
// the same photo can need a different description in a project page
// than in a listing, and only the context knows which.
Schema::table('pages', function (Blueprint $table) {
$table->jsonb('featured_image_alt')->nullable()->after('featured_image');
});
Schema::table('projects', function (Blueprint $table) {
$table->jsonb('cover_image_alt')->nullable()->after('cover_image');
});
}
public function down(): void
{
Schema::table('projects', fn (Blueprint $table) => $table->dropColumn('cover_image_alt'));
Schema::table('pages', fn (Blueprint $table) => $table->dropColumn('featured_image_alt'));
Schema::dropIfExists('media');
}
};

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Models\Media;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Registers the images that already existed.
*
* Before this, an image was a path in a column and nothing else. Those files
* are still there, so they get a media row like any other upload; the actual
* work — reading dimensions, deriving versions — is left to the queue, or to
* `php artisan media:process`, which the scheduler runs anyway.
*
* Nothing is deleted or moved. If the uploads predate `MEDIA_DISK` they may
* be sitting on a different disk than the one now configured, and moving
* someone's files during a migration is not this migration's business.
*/
return new class extends Migration
{
public function up(): void
{
$disk = (string) config('media.disk', 'public');
$sources = [
['table' => 'pages', 'column' => 'featured_image'],
['table' => 'projects', 'column' => 'cover_image'],
['table' => 'members', 'column' => 'avatar'],
];
$paths = collect($sources)
->flatMap(fn (array $source) => DB::table($source['table'])
->whereNotNull($source['column'])
->where($source['column'], '!=', '')
->pluck($source['column']))
->map(fn (string $path) => ltrim($path, '/'))
->unique()
->values();
foreach ($paths as $path) {
Media::query()->firstOrCreate(['disk' => $disk, 'path' => $path]);
}
}
public function down(): void
{
// The table is dropped by the migration that created it; there is
// nothing to undo here that is not undone there.
}
};

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* More than one image on a record.
*
* A project's cover says what it is; a gallery is where the before-and-after
* goes, and the screenshots that make a write-up worth reading. Those need an
* order and a caption, which is exactly what a second string column on the
* project could never carry.
*
* Polymorphic because "several images, in an order, each described" is not a
* project-shaped problem — it is the same problem wherever it appears.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('image_attachments', function (Blueprint $table) {
$table->id();
$table->morphs('attachable');
// Room for a record to hold more than one set of images later on
// without another table.
$table->string('collection', 40)->default('gallery');
// The stored path. Its dimensions and derived versions live in
// `media`, joined on this, exactly as for a cover.
$table->string('path', 500);
$table->jsonb('alt')->nullable();
// Distinct from alt on purpose: alt describes the image for
// someone who cannot see it, a caption is shown to everyone.
// "Before" and "after" is a caption, not a description.
$table->jsonb('caption')->nullable();
$table->unsignedInteger('position')->default(0);
$table->timestamps();
$table->index(['attachable_type', 'attachable_id', 'collection', 'position'], 'image_attachments_lookup');
});
}
public function down(): void
{
Schema::dropIfExists('image_attachments');
}
};

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* An end date for content that is only true for a while.
*
* `published_at` already worked in both directions — a date in the future
* keeps a record off the public site until it arrives — but there was no way
* to say when something should stop being shown. A notice about a workshop
* next month, a call for a one-off event, a banner for a campaign: all of them
* need somebody to remember to take them down, and nobody ever does.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('pages', function (Blueprint $table) {
$table->timestamp('unpublish_at')->nullable()->after('published_at');
$table->index('unpublish_at');
});
Schema::table('projects', function (Blueprint $table) {
$table->timestamp('unpublish_at')->nullable()->after('published_at');
$table->index('unpublish_at');
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->dropIndex(['unpublish_at']);
$table->dropColumn('unpublish_at');
});
Schema::table('pages', function (Blueprint $table) {
$table->dropIndex(['unpublish_at']);
$table->dropColumn('unpublish_at');
});
}
};

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The pieces a landing page is made of.
*
* Until now the site could say what the collective *built* (projects) and who
* builds it (members), but not what it *offers*, who it was for, who vouches
* for it, or where it has been seen. Those are four different questions and
* they get four tables — deliberately not a page-builder: the frontend decides
* what to compose and in what order, this only holds the pieces.
*/
return new class extends Migration
{
public function up(): void
{
/*
* What the collective offers, as opposed to what it has already done.
* A landing page is built on these; a portfolio without them is a list
* of things with no explanation of what you would be hiring.
*/
Schema::create('services', function (Blueprint $table) {
$table->id();
$table->jsonb('title');
$table->jsonb('slug');
$table->jsonb('tagline')->nullable();
$table->jsonb('description')->nullable();
$table->jsonb('meta_title')->nullable();
$table->jsonb('meta_description')->nullable();
$table->string('icon')->nullable();
$table->boolean('is_published')->default(false);
$table->boolean('is_featured')->default(false);
$table->unsignedInteger('position')->default(0);
$table->timestamps();
$table->softDeletes();
$table->index(['is_published', 'position']);
});
/*
* The organisations the work was for.
*
* This was two free-text columns on `projects`, which meant the same
* association typed twice was two different entities, and there was
* nowhere to keep a logo. A table gives the logo wall for free and
* stops the duplicates.
*/
Schema::create('clients', function (Blueprint $table) {
$table->id();
// A proper noun: an association is called what it is called in
// every language, so the name is not translatable. Only the prose
// about it is.
$table->string('name');
$table->string('slug')->unique();
$table->jsonb('sector')->nullable();
$table->string('logo')->nullable();
$table->string('url')->nullable();
// Not everybody wants to appear on somebody else's website.
$table->boolean('is_public')->default(true);
$table->unsignedInteger('position')->default(0);
$table->timestamps();
$table->softDeletes();
});
/*
* What somebody else says about the work, which is worth more than
* anything the collective says about itself.
*/
Schema::create('testimonials', function (Blueprint $table) {
$table->id();
$table->jsonb('quote');
/*
* The language it was actually said in. Translating a quote means
* putting words in somebody's mouth, so the frontend can say
* "translated from Spanish" rather than pretending they said it
* that way.
*/
$table->string('original_locale', 5)->default('es');
$table->string('author_name');
$table->jsonb('author_role')->nullable();
$table->string('avatar')->nullable();
// What it vouches for. Attached to a client, a project or a
// service rather than to "the home page", so the frontend asks for
// the ones belonging to whatever it is rendering.
$table->foreignId('client_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('service_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('is_public')->default(true);
$table->unsignedInteger('position')->default(0);
$table->date('given_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
/*
* Conference talks and the like: the cheapest content there is to keep
* a site looking alive, and the only kind that says the collective
* exists outside its own website.
*/
Schema::create('talks', function (Blueprint $table) {
$table->id();
// A talk's title is the name of that talk, given in one language.
// Translating it would invent a talk that was never given.
$table->string('title');
$table->string('language', 5)->default('es');
$table->jsonb('summary')->nullable();
$table->string('event');
$table->string('city')->nullable();
$table->date('held_at')->nullable();
$table->string('video_url')->nullable();
$table->string('slides_url')->nullable();
$table->foreignId('member_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('is_public')->default(true);
$table->timestamps();
$table->index(['is_public', 'held_at']);
});
Schema::create('project_service', function (Blueprint $table) {
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('service_id')->constrained()->cascadeOnDelete();
$table->primary(['project_id', 'service_id']);
});
Schema::create('service_technology', function (Blueprint $table) {
$table->foreignId('service_id')->constrained()->cascadeOnDelete();
$table->foreignId('technology_id')->constrained()->cascadeOnDelete();
$table->primary(['service_id', 'technology_id']);
});
}
public function down(): void
{
Schema::dropIfExists('service_technology');
Schema::dropIfExists('project_service');
Schema::dropIfExists('talks');
Schema::dropIfExists('testimonials');
Schema::dropIfExists('clients');
Schema::dropIfExists('services');
}
};

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* `beneficiary` was a name typed into every project by hand.
*
* Two projects for the same association were two unrelated strings, a typo
* made a third one, and there was nowhere to put a logo. This turns what was
* already written into real records and points the projects at them; nothing
* is lost, and the wall of logos becomes possible.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->foreignId('client_id')->nullable()->after('lead_member_id')
->constrained()->nullOnDelete();
});
$this->convertExistingBeneficiaries();
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn(['beneficiary', 'beneficiary_url']);
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->string('beneficiary')->nullable();
$table->string('beneficiary_url')->nullable();
});
foreach (DB::table('projects')->whereNotNull('client_id')->get() as $project) {
$client = DB::table('clients')->find($project->client_id);
DB::table('projects')->where('id', $project->id)->update([
'beneficiary' => $client?->name,
'beneficiary_url' => $client?->url,
]);
}
Schema::table('projects', function (Blueprint $table) {
$table->dropForeign(['client_id']);
$table->dropColumn('client_id');
});
}
/**
* One client per distinct name, matched case-insensitively so "Cruz Roja"
* and "cruz roja" converge instead of becoming two organisations.
*/
protected function convertExistingBeneficiaries(): void
{
$clients = [];
$position = 0;
$projects = DB::table('projects')
->whereNotNull('beneficiary')
->where('beneficiary', '!=', '')
->get(['id', 'beneficiary', 'beneficiary_url']);
foreach ($projects as $project) {
$name = trim((string) $project->beneficiary);
$key = Str::lower($name);
if (! isset($clients[$key])) {
$slug = Str::slug($name) ?: 'cliente-'.$project->id;
// A slug collision between two genuinely different names would
// otherwise fail the unique index mid-migration.
while (DB::table('clients')->where('slug', $slug)->exists()) {
$slug .= '-'.$project->id;
}
$clients[$key] = DB::table('clients')->insertGetId([
'name' => $name,
'slug' => $slug,
'url' => $project->beneficiary_url,
'is_public' => true,
'position' => $position++,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('projects')->where('id', $project->id)->update([
'client_id' => $clients[$key],
]);
}
}
};

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Services get the same window as pages and projects.
*
* They arrived with a bare boolean, which meant the one thing you cannot do
* with them is the obvious one: line up a new offer and have it appear on the
* day you start offering it. There was never a reason for them to be the
* exception.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('services', function (Blueprint $table) {
$table->timestamp('published_at')->nullable()->after('is_published');
$table->timestamp('unpublish_at')->nullable()->after('published_at');
$table->index('unpublish_at');
});
// Everything already on the site keeps being on it.
DB::table('services')
->where('is_published', true)
->whereNull('published_at')
->update(['published_at' => now()]);
}
public function down(): void
{
Schema::table('services', function (Blueprint $table) {
$table->dropIndex(['unpublish_at']);
$table->dropColumn(['published_at', 'unpublish_at']);
});
}
};

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Where a URL that used to work should go now.
*
* Renaming a slug breaks every link anybody ever shared, every bookmark and
* every search result — silently, because nothing here notices and the
* frontend only finds out when a visitor gets a 404. This is the net.
*
* It is data rather than frontend configuration: only this side knows that a
* slug changed, and only at the moment it changes.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('redirects', function (Blueprint $table) {
$table->id();
// Paths as the frontend sees them, leading slash included:
// "/proyectos/nombre-viejo" => "/proyectos/nombre-nuevo".
$table->string('from', 500);
$table->string('to', 500);
$table->unsignedSmallInteger('status')->default(301);
// Null when it applies to every language — a hand-written rule
// usually does; an automatic one always knows its locale.
$table->string('locale', 5)->nullable();
// Set when the redirect was created by a slug change rather than
// by a person, so the two can be told apart in the panel.
$table->nullableMorphs('source');
$table->boolean('is_enabled')->default(true);
$table->unsignedInteger('hits')->default(0);
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->unique(['from', 'locale']);
$table->index(['is_enabled', 'from']);
});
}
public function down(): void
{
Schema::dropIfExists('redirects');
}
};

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Who changed what, and what it said before.
*
* Several people edit the same translated JSON with no record of any of it.
* The gap that matters most is publishing: `pages.publish` exists as a
* separate permission precisely because putting something on the public site
* is a bigger act than writing it — and until now nothing recorded who did it.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('revisions', function (Blueprint $table) {
$table->id();
$table->morphs('revisionable');
// Null for something done by the scheduler or a command: honest
// about it rather than blaming the last person who logged in.
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('user_name')->nullable();
$table->string('event', 20);
/*
* Only what changed, old and new. Storing whole records would make
* this table larger than the content it describes.
*
* Not called `changes`: Eloquent declares a `$changes` property of
* its own for dirty tracking, and a column of that name is shadowed
* by it — `$revision->changes` would silently return the ORM's
* array instead of this column.
*/
$table->jsonb('changeset')->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['revisionable_type', 'revisionable_id', 'created_at'], 'revisions_record_index');
});
}
public function down(): void
{
Schema::dropIfExists('revisions');
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A real logo for a technology, beside the colour it already had.
*
* `icon` holds the name of an icon from a set the frontend happens to bundle,
* which works until the technology is not in that set — and most are not.
* A file settles it, and the tech radar is the part of the site people share.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('technologies', function (Blueprint $table) {
$table->string('logo')->nullable()->after('icon');
});
}
public function down(): void
{
Schema::table('technologies', function (Blueprint $table) {
$table->dropColumn('logo');
});
}
};

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Addresses visitors asked for and the frontend could not resolve.
*
* Redirects already cover the one case this side knows about: a slug changed
* here, so the old address is written down on the way past. Everything else is
* invisible — links from the previous website, addresses printed on paper,
* somebody else's blog post linking a page that moved before this application
* existed. Those only fail on the frontend, in front of a visitor, and nothing
* here ever hears about it.
*
* So the frontend reports them, and this is where they land: the missing half
* of the redirect feature, which turns it from reactive into something that
* can be acted on.
*
* A counter, deliberately, not a log. One row per (path, locale) with a hit
* count keeps a bot scanning for /wp-admin.php from becoming ten thousand
* rows, and the count is the number that decides whether an address is worth a
* redirect at all.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('missing_urls', function (Blueprint $table) {
$table->id();
// The path as the frontend saw it, normalised the same way a
// redirect is, so the two can be compared without guessing.
$table->string('path', 500);
// Which language was being rendered. Never null, unlike the same
// column on `redirects`: it is half of the unique key below, and
// in Postgres and SQLite a null never equals another null — the
// upsert would insert a new row per sighting instead of counting.
// The API fills it from the negotiated locale when the report does
// not carry one, which it always can.
$table->string('locale', 5);
$table->unsignedInteger('hits')->default(1);
// Where the visitor came from. The most valuable column here: a
// referrer on our own domain means our own content links to a dead
// address, which is a content bug rather than somebody else's
// stale link.
$table->string('referrer', 500)->nullable();
$table->string('status', 20)->default('pending');
$table->timestamp('first_seen_at')->nullable();
$table->timestamp('last_seen_at')->nullable();
// When somebody dealt with it, by writing a redirect or by
// deciding it was noise.
$table->timestamp('resolved_at')->nullable();
$table->timestamps();
// What makes this a counter instead of a log.
$table->unique(['path', 'locale']);
// The list is read sorted by "worst first, still pending".
$table->index(['status', 'hits']);
});
}
public function down(): void
{
Schema::dropIfExists('missing_urls');
}
};

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Asking somebody to look at something before it goes live.
*
* `pages.publish` has always been a permission of its own, because putting
* something on the website is a bigger act than writing it. What was missing
* was the other half: somebody holding only `pages.update` could write a draft
* and then had **no way to say it was ready**. The status field that would
* have said so is disabled for exactly those users, so the handover happened
* in a chat message, or did not happen at all.
*
* Two pieces. The columns hold the current state — is this waiting, who asked,
* who was asked — because that is what a query needs to answer "what is
* waiting for me". The table holds the conversation, which is a different
* thing from the change history: revisions record what the text used to say,
* and none of them can record "the photo is missing".
*
* Only on the three models where publishing is a separate permission. Adding
* review to a testimonial would be ceremony: whoever may write one may publish
* it, so there is nobody to ask.
*/
return new class extends Migration
{
/**
* @var array<int, string>
*/
protected array $tables = ['pages', 'projects', 'services'];
public function up(): void
{
foreach ($this->tables as $table) {
Schema::table($table, function (Blueprint $table) {
// The single source of truth for "is this waiting for
// somebody". Null means it is not.
$table->timestamp('review_requested_at')->nullable();
$table->foreignId('review_requested_by')->nullable()->constrained('users')->nullOnDelete();
// Optional. A review nobody is named for goes to everyone who
// could publish it — better than a queue addressed to nobody,
// and better than forcing the author to guess who is free.
$table->foreignId('reviewer_id')->nullable()->constrained('users')->nullOnDelete();
$table->index('review_requested_at');
});
}
Schema::create('review_comments', function (Blueprint $table) {
$table->id();
$table->morphs('reviewable');
// Nullable, like a revision's: work sent back by a command or a
// scheduled task belongs to nobody, and blaming the last person to
// sign in would be worse than saying so.
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('user_name')->nullable();
$table->string('event', 20);
// Required when sending work back, optional everywhere else.
// Returning somebody's work without saying why is the thing that
// makes a review process resented.
$table->text('body')->nullable();
$table->timestamps();
$table->index(['reviewable_type', 'reviewable_id', 'id']);
});
}
public function down(): void
{
Schema::dropIfExists('review_comments');
foreach ($this->tables as $table) {
Schema::table($table, function (Blueprint $table) {
$table->dropConstrainedForeignKey('review_requested_by');
$table->dropConstrainedForeignKey('reviewer_id');
$table->dropColumn('review_requested_at');
});
}
}
};

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* How often each record was actually looked at.
*
* The panel has always been able to say what exists and what is published. It
* has never been able to say whether any of it is read, and that is the fact
* behind most editorial decisions nobody can currently make: which language is
* worth translating into next, whether the featured project is one anybody
* opens, whether a service page earns the work of keeping it accurate.
*
* Deliberately not analytics. There is no visitor here and no way to build
* one: no address, no session, no user agent, no referrer, no time of day.
* One row per record, per language, per day, holding a number — which is
* everything the questions above need and nothing that could identify anybody.
*
* Attached to the record rather than to a path, because a slug can change and
* a redirect can be written; the history of what people read should survive
* both, and "/proyectos/nombre-viejo" is not a different project.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('content_views', function (Blueprint $table) {
$table->id();
$table->morphs('viewable');
$table->string('locale', 5);
// A date, not a timestamp. Anything finer starts to describe when
// one person was reading.
$table->date('day');
$table->unsignedInteger('count')->default(0);
$table->timestamps();
// What keeps this a tally instead of a log.
$table->unique(['viewable_type', 'viewable_id', 'locale', 'day']);
// The two ways it is read: totals for one record, and totals for
// one stretch of time across everything.
$table->index(['day', 'viewable_type']);
});
}
public function down(): void
{
Schema::dropIfExists('content_views');
}
};

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Call;
use App\Models\Category;
use App\Models\Client;
use App\Models\Member;
use App\Models\Page;
use App\Models\Project;
use App\Models\Repository;
use App\Models\Service;
use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Always required: the app cannot authorise anyone without its roles
// and permissions in place.
$this->call(RolePermissionSeeder::class);
// Sample content only outside production. Real user accounts are
// never seeded — they are created by the OAuth login flow.
if (! app()->environment('production')) {
$this->seedDemoContent();
}
}
protected function seedDemoContent(): void
{
if (Page::query()->exists()) {
return;
}
$categories = Category::factory()->count(4)->create();
Page::factory()
->count(12)
->published()
->recycle($categories)
->create();
Page::factory()->count(4)->draft()->recycle($categories)->create();
// A few pages deliberately left translated in the default locale only,
// so the translation filters and dashboard stat have something to show.
Page::factory()->count(3)->untranslated()->recycle($categories)->create();
$this->seedCollective();
$this->command?->info('Seeded demo content for the collective.');
}
/**
* The collective's own content: who we are, what we build, what we use and
* what we need.
*/
protected function seedCollective(): void
{
$technologies = Technology::factory()->count(12)->create();
$members = Member::factory()->count(8)->create();
Member::factory()->count(2)->founder()->create();
Member::factory()->count(2)->past()->create();
Member::all()->each(
fn (Member $member) => $member->technologies()->sync(
$technologies->random(rand(2, 5))->pluck('id'),
),
);
$projects = Project::factory()
->count(6)
->published()
->create()
->each(function (Project $project) use ($technologies, $members): void {
$project->technologies()->sync($technologies->random(rand(2, 5))->pluck('id'));
$project->members()->sync(
$members->random(rand(1, 3))->mapWithKeys(fn (Member $m) => [
$m->id => ['role' => fake()->randomElement(['dev', 'diseño', 'infra'])],
]),
);
$project->update(['lead_member_id' => $members->random()->id]);
Repository::factory()->for($project)->create();
});
// One archived project with a write-up, and one unpublished, so the
// public API's filtering has something real to exclude.
Project::factory()->withPostmortem()->published()->create();
Project::factory()->draft()->create();
$this->seedShowcase($projects, $members, $technologies);
// Open calls, some attached to a project and some standalone.
Call::factory()
->count(4)
->create(['project_id' => $projects->random()->id])
->each(fn (Call $call) => $call->technologies()->sync(
$technologies->random(rand(1, 3))->pluck('id'),
));
Call::factory()->count(2)->create(['project_id' => null]);
Call::factory()->closed()->create(['project_id' => null]);
// Mirrored issues, so the "good first issues" endpoint is not empty
// before anyone has configured a real git token.
Repository::all()->each(function (Repository $repository): void {
foreach (range(1, rand(1, 4)) as $number) {
$repository->issues()->create([
'number' => $number,
'title' => fake()->sentence(6),
'html_url' => "{$repository->html_url}/issues/{$number}",
'labels' => ['good first issue'],
'is_good_first_issue' => true,
'is_help_wanted' => fake()->boolean(),
'author' => 'maintainer',
'assignee' => fake()->boolean(25) ? 'someone' : null,
'comments' => rand(0, 8),
'issue_created_at' => now()->subDays(rand(1, 90)),
'issue_updated_at' => now()->subDays(rand(0, 10)),
]);
}
});
}
/**
* What the landing page is built from.
*
* Without these a fresh install has projects and members but nothing to
* compose a front page out of, which makes it impossible to tell whether
* the frontend works.
*
* @param Collection<int, Project> $projects
* @param Collection<int, Member> $members
* @param Collection<int, Technology> $technologies
*/
protected function seedShowcase($projects, $members, $technologies): void
{
// Organisations the work was for, each with at least one project so
// the wall of logos is not empty.
$clients = Client::factory()->count(4)->create();
$projects->each(fn (Project $project) => $project->update([
'client_id' => $clients->random()->id,
]));
$services = Service::factory()->count(4)->create();
$services->each(function (Service $service) use ($projects, $technologies): void {
$service->technologies()->sync($technologies->random(rand(2, 4))->pluck('id'));
$service->projects()->sync($projects->random(rand(1, 3))->pluck('id'));
});
// One published service with nothing behind it, so the health check
// that calls that out has something real to find.
Service::factory()->create(['is_featured' => true]);
Service::factory()->draft()->create();
Testimonial::factory()->count(3)->create([
'client_id' => $clients->random()->id,
]);
Testimonial::factory()->create(['service_id' => $services->random()->id]);
Testimonial::factory()->create(['project_id' => $projects->random()->id]);
Talk::factory()->count(5)->create([
'member_id' => $members->random()->id,
]);
// And one nobody is credited with, for the same reason.
Talk::factory()->create(['member_id' => null]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Support\Permissions;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Brings the database in line with App\Support\Permissions.
*
* Idempotent: safe to re-run after every deploy. Permissions removed from the
* catalogue are deleted, so a permission that no longer exists in code cannot
* keep granting access.
*/
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
$catalogue = Permissions::all();
foreach ($catalogue as $name) {
Permission::findOrCreate($name, 'web');
}
// Drop permissions that were retired from the catalogue, along with
// their role and user assignments.
Permission::query()->whereNotIn('name', $catalogue)->delete();
foreach (Permissions::roleDefinitions() as $roleName => $permissions) {
$role = Role::findOrCreate($roleName, 'web');
// syncPermissions is what makes this idempotent: the role ends up
// with exactly the definition in code, no more and no less.
// super_admin gets an empty set on purpose — its power comes from
// the Gate::before hook in AuthServiceProvider.
$role->syncPermissions($permissions);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->command?->info(sprintf(
'Synced %d permissions across %d roles.',
count($catalogue),
count(Permissions::roleDefinitions()),
));
}
}