Files
landing-api/app/Models/Page.php
Josep Piguillem 3897113313 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>
2026-08-19 17:55:27 +02:00

198 lines
5.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\PageStatus;
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\PageFactory;
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\SoftDeletes;
use Spatie\Translatable\HasTranslations;
/**
* @property array<string, string> $title
* @property array<string, string> $slug
*/
#[Fillable([
'category_id', 'author_id', 'title', 'slug', 'excerpt', 'body',
'meta_title', 'meta_description', 'status', 'featured_image',
'featured_image_alt', 'is_featured', 'published_at', 'unpublish_at',
])]
class Page extends Model
{
/** @use HasFactory<PageFactory> */
use BumpsContentVersion, HasContentViews, HasFactory, HasImages, HasPublicationWindow, HasReviews, HasRevisions, HasTranslations, RecordsSlugChanges, SoftDeletes;
/**
* Columns stored as a locale => value JSON map.
*
* @var array<int, string>
*/
public array $translatable = [
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description',
// What the featured image shows, described in each language.
'featured_image_alt',
];
/**
* Mirrors the column default so `status` is a PageStatus from the moment a
* model is instantiated. Relying on the database default alone leaves the
* attribute null until the row is re-read, which anything reading
* `$page->status->value` would trip over.
*
* @var array<string, mixed>
*/
protected $attributes = [
'status' => PageStatus::Draft->value,
];
protected function casts(): array
{
return [
'status' => PageStatus::class,
'is_featured' => 'boolean',
'published_at' => 'datetime',
'unpublish_at' => 'datetime',
];
}
/**
* Whether the site is showing this right now — what "published" means for
* a page, which has both a status and a window.
*/
public function isPublishedNow(): bool
{
return $this->status === PageStatus::Published
&& ! $this->isScheduled()
&& ! $this->hasExpired();
}
/**
* A page is the one model here whose status can already say "in review",
* and that value is shown in the table and offered as a filter. Asking for
* a review writes it, so the column keeps telling the truth; sending the
* work back returns it to a draft.
*
* Only ever called from the review methods, which is what stops the two
* from disagreeing. An archived page is left alone: it is not a draft, and
* silently turning it into one would throw away a deliberate decision.
*/
protected function syncReviewStatus(bool $awaiting): void
{
if ($awaiting) {
if ($this->status === PageStatus::Draft) {
$this->status = PageStatus::Review;
}
return;
}
if ($this->status === PageStatus::Review) {
$this->status = PageStatus::Draft;
}
}
/**
* The `site.paths` key this model publishes under, so a slug change knows
* which URL it just broke.
*/
public function publicPathType(): string
{
return 'page';
}
/**
* @return array<int, string>
*/
public function imageAttributes(): array
{
return ['featured_image'];
}
/**
* @return BelongsTo<Media, $this>
*/
public function featuredMedia(): BelongsTo
{
return $this->imageRelation('featured_image');
}
/**
* @return BelongsTo<Category, $this>
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
/**
* Publicly visible pages: published and not scheduled for the future.
*
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopePublished(Builder $query): Builder
{
return $query
->where('status', PageStatus::Published)
->where(fn (Builder $q) => $q
->whereNull('published_at')
->orWhere('published_at', '<=', now()))
// Both dates are enforced in one place, so nothing that reads
// published content has to remember the second one.
->where(fn (Builder $q) => $q
->whereNull('unpublish_at')
->orWhere('unpublish_at', '>', now()));
}
/**
* Match a slug within one locale. Slugs live inside a JSON map, so this
* targets the locale's key rather than the column as a whole.
*
* @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);
}
/**
* Restrict to records that actually carry a translation for a locale —
* used by the API's `?translated_in=ca` filter so clients can avoid
* fallback content.
*
* @param Builder<$this> $query
* @return Builder<$this>
*/
public function scopeTranslatedIn(Builder $query, string $locale): Builder
{
return $query
->whereNotNull("title->{$locale}")
->where("title->{$locale}", '!=', '');
}
}