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:
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
70
database/factories/ApplicationFactory.php
Normal file
70
database/factories/ApplicationFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
61
database/factories/CallFactory.php
Normal file
61
database/factories/CallFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
47
database/factories/CategoryFactory.php
Normal file
47
database/factories/CategoryFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
50
database/factories/ClientFactory.php
Normal file
50
database/factories/ClientFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
38
database/factories/ImageAttachmentFactory.php
Normal file
38
database/factories/ImageAttachmentFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
57
database/factories/MediaFactory.php
Normal file
57
database/factories/MediaFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
database/factories/MemberFactory.php
Normal file
69
database/factories/MemberFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
56
database/factories/MissingUrlFactory.php
Normal file
56
database/factories/MissingUrlFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
91
database/factories/PageFactory.php
Normal file
91
database/factories/PageFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
93
database/factories/ProjectFactory.php
Normal file
93
database/factories/ProjectFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
36
database/factories/RedirectFactory.php
Normal file
36
database/factories/RedirectFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
76
database/factories/RepositoryFactory.php
Normal file
76
database/factories/RepositoryFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
57
database/factories/ServiceFactory.php
Normal file
57
database/factories/ServiceFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
44
database/factories/TalkFactory.php
Normal file
44
database/factories/TalkFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
60
database/factories/TechnologyFactory.php
Normal file
60
database/factories/TechnologyFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
46
database/factories/TestimonialFactory.php
Normal file
46
database/factories/TestimonialFactory.php
Normal 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();
|
||||
}
|
||||
}
|
||||
59
database/factories/UserFactory.php
Normal file
59
database/factories/UserFactory.php
Normal 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));
|
||||
}
|
||||
}
|
||||
69
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
69
database/migrations/0001_01_01_000000_create_users_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
80
database/migrations/2026_08_13_160000_create_media_table.php
Normal file
80
database/migrations/2026_08_13_160000_create_media_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
169
database/migrations/2026_08_15_090000_create_showcase_tables.php
Normal file
169
database/migrations/2026_08_15_090000_create_showcase_tables.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
182
database/seeders/DatabaseSeeder.php
Normal file
182
database/seeders/DatabaseSeeder.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
54
database/seeders/RolePermissionSeeder.php
Normal file
54
database/seeders/RolePermissionSeeder.php
Normal 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()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user