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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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