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>
265 lines
7.4 KiB
PHP
265 lines
7.4 KiB
PHP
<?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);
|
|
}
|
|
}
|