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

264
app/Models/Project.php Normal file
View File

@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\ProjectStatus;
use App\Models\Concerns\BumpsContentVersion;
use App\Models\Concerns\HasContentViews;
use App\Models\Concerns\HasImages;
use App\Models\Concerns\HasPublicationWindow;
use App\Models\Concerns\HasReviews;
use App\Models\Concerns\HasRevisions;
use App\Models\Concerns\RecordsSlugChanges;
use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Translatable\HasTranslations;
/**
* Something the collective has built or is building.
*
* `decisions` and `postmortem` are the reason this model exists rather than a
* generic Page: they are what a real consultancy cannot publish and a hobby
* collective can, and they are what other developers actually read.
*/
#[Fillable([
'lead_member_id', 'title', 'slug', 'tagline', 'description', 'decisions',
'postmortem', 'meta_title', 'meta_description', 'status', 'client_id',
'demo_url', 'cover_image', 'cover_image_alt',
'is_featured', 'needs_help',
'started_at', 'ended_at', 'published_at', 'unpublish_at', 'position',
])]
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
use BumpsContentVersion, HasContentViews, HasFactory, HasImages, HasPublicationWindow, HasReviews, HasRevisions, HasTranslations, RecordsSlugChanges, SoftDeletes;
/** @var array<int, string> */
public array $translatable = [
'title', 'slug', 'tagline', 'description', 'decisions', 'postmortem',
'meta_title', 'meta_description',
// What the cover shows, described in each language.
'cover_image_alt',
];
/**
* Mirrors the column default so `status` is a ProjectStatus from the
* moment a model is instantiated, not only after a re-read.
*
* @var array<string, mixed>
*/
protected $attributes = [
'status' => ProjectStatus::Idea->value,
];
protected function casts(): array
{
return [
'status' => ProjectStatus::class,
'is_featured' => 'boolean',
'needs_help' => 'boolean',
'started_at' => 'date',
'ended_at' => 'date',
'published_at' => 'datetime',
'unpublish_at' => 'datetime',
'position' => 'integer',
];
}
protected static function booted(): void
{
// A morph relation has no foreign key to cascade for it, so a project
// erased for good would otherwise leave its gallery behind. Soft
// deletes keep it, which is the point of a soft delete.
static::forceDeleted(fn (Project $project) => $project->images()->delete());
}
/**
* Whether the site is showing this right now. A project has no status of
* its own for this — its dates are the whole answer.
*/
public function isPublishedNow(): bool
{
return $this->published_at !== null
&& ! $this->isScheduled()
&& ! $this->hasExpired();
}
/**
* The `site.paths` key this model publishes under, so a slug change knows
* which URL it just broke.
*/
public function publicPathType(): string
{
return 'project';
}
/**
* @return array<int, string>
*/
public function imageAttributes(): array
{
return ['cover_image'];
}
/**
* The gallery: screenshots, before-and-after, whatever the write-up needs.
*
* Ordered here rather than at every call site, because an unordered
* gallery is a different gallery each time it is read.
*
* @return MorphMany<ImageAttachment, $this>
*/
public function images(): MorphMany
{
return $this->morphMany(ImageAttachment::class, 'attachable')
->where('collection', 'gallery')
->ordered();
}
/**
* Dimensions and derived versions of the cover, joined on the stored path.
*
* @return BelongsTo<Media, $this>
*/
public function coverMedia(): BelongsTo
{
return $this->imageRelation('cover_image');
}
/**
* Who the work was for. Used to be a name typed in by hand, which made the
* same association two different strings.
*
* @return BelongsTo<Client, $this>
*/
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
/**
* @return BelongsToMany<Service, $this>
*/
public function services(): BelongsToMany
{
return $this->belongsToMany(Service::class);
}
/**
* @return HasMany<Testimonial, $this>
*/
public function testimonials(): HasMany
{
return $this->hasMany(Testimonial::class);
}
/**
* @return HasMany<Talk, $this>
*/
public function talks(): HasMany
{
return $this->hasMany(Talk::class);
}
/**
* @return BelongsTo<Member, $this>
*/
public function lead(): BelongsTo
{
return $this->belongsTo(Member::class, 'lead_member_id');
}
/**
* @return BelongsToMany<Member, $this>
*/
public function members(): BelongsToMany
{
return $this->belongsToMany(Member::class)->withPivot('role');
}
/**
* @return BelongsToMany<Technology, $this>
*/
public function technologies(): BelongsToMany
{
return $this->belongsToMany(Technology::class);
}
/**
* @return HasMany<Repository, $this>
*/
public function repositories(): HasMany
{
return $this->hasMany(Repository::class);
}
/**
* @return HasMany<Call, $this>
*/
public function calls(): HasMany
{
return $this->hasMany(Call::class);
}
/**
* The repository whose stats represent the project as a whole.
*/
public function primaryRepository(): ?Repository
{
return $this->repositories->firstWhere('is_primary', true)
?? $this->repositories->first();
}
/**
* Visible on the public site: published, and not scheduled for later.
*
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopePublished(Builder $query): Builder
{
return $query
->whereNotNull('published_at')
->where('published_at', '<=', now())
->where(fn (Builder $q) => $q
->whereNull('unpublish_at')
->orWhere('unpublish_at', '>', now()));
}
/**
* Projects actively looking for contributors, either flagged directly or
* carrying an open call.
*
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeNeedingHelp(Builder $query): Builder
{
return $query->where(fn (Builder $q) => $q
->where('needs_help', true)
->orWhereHas('calls', fn (Builder $c) => $c->open()));
}
/**
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeWhereSlug(Builder $query, string $slug, ?string $locale = null): Builder
{
$locale ??= app()->getLocale();
return $query->where("slug->{$locale}", $slug);
}
}