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:
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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user