Files
landing-api/app/Models/Page.php
Josep Piguillem 9f970808ca
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tag blog posts and sign them with a member
Tags are named in every language and made from the page form; the byline is
the member who signs a post on the site. The public API sends both, filters
posts by tag and lists the tags in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:01:18 +02:00

218 lines
6.4 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\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Translatable\HasTranslations;
/**
* @property array<string, string> $title
* @property array<string, string> $slug
*/
#[Fillable([
'category_id', 'author_id', 'byline_member_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');
}
/**
* Who signs it on the site: a member, with a profile to link to. Not the
* account that typed it in, which is `author` and stays in the panel.
*
* @return BelongsTo<Member, $this>
*/
public function byline(): BelongsTo
{
return $this->belongsTo(Member::class, 'byline_member_id');
}
/**
* @return BelongsToMany<Tag, $this>
*/
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
/**
* 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}", '!=', '');
}
}