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:
264
app/Models/Project.php
Normal file
264
app/Models/Project.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user