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:
131
app/Models/Application.php
Normal file
131
app/Models/Application.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ApplicationKind;
|
||||
use App\Enums\ApplicationStatus;
|
||||
use Database\Factories\ApplicationFactory;
|
||||
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 Illuminate\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* Somebody answered.
|
||||
*
|
||||
* This is the only model in the application written by the public rather than
|
||||
* by the collective, which is why everything it carries is either validated
|
||||
* input or derived here — never something the sender got to choose freely.
|
||||
*/
|
||||
#[Fillable([
|
||||
'call_id', 'call_title', 'kind', 'status', 'name', 'email', 'organisation',
|
||||
'message', 'hours_per_week', 'links', 'locale', 'ip_hash',
|
||||
])]
|
||||
class Application extends Model
|
||||
{
|
||||
/** @use HasFactory<ApplicationFactory> */
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
protected $attributes = [
|
||||
'kind' => ApplicationKind::Contribute->value,
|
||||
'status' => ApplicationStatus::New->value,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'kind' => ApplicationKind::class,
|
||||
'status' => ApplicationStatus::class,
|
||||
'hours_per_week' => 'integer',
|
||||
'links' => 'array',
|
||||
'handled_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Call, $this>
|
||||
*/
|
||||
public function call(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Call::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function handler(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'handled_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Still waiting for a human. This is the number the navigation badge
|
||||
* shows, and the only one that matters day to day.
|
||||
*
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', ApplicationStatus::New);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeNotSpam(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', '!=', ApplicationStatus::Spam);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the submission along and record who did it, so an inbox worked by
|
||||
* several people does not turn into two replies or none.
|
||||
*/
|
||||
public function markAs(ApplicationStatus $status, ?User $by = null): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'status' => $status,
|
||||
'handled_by' => $status->isHandled() ? ($by?->id ?? $this->handled_by) : null,
|
||||
'handled_at' => $status->isHandled() ? now() : null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sender is asking about, whether or not the call still exists.
|
||||
*/
|
||||
public function subject(): string
|
||||
{
|
||||
// The live title when the call is at hand — it may have been reworded
|
||||
// since — and the snapshot otherwise, including once the call itself
|
||||
// is gone. Never a lazy load: this is read inside table rows.
|
||||
if ($this->relationLoaded('call') && $this->call !== null) {
|
||||
return $this->call->getTranslation('title', $this->locale);
|
||||
}
|
||||
|
||||
return $this->call_title ?? __('panel.applications.general_enquiry');
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyed hash of the sender's address.
|
||||
*
|
||||
* Enough to spot a flood coming from one place; not enough to turn this
|
||||
* table into a log of who visited the site. The application key is the
|
||||
* secret, so the hashes are useless if the table alone leaks.
|
||||
*/
|
||||
public static function hashIp(?string $ip): ?string
|
||||
{
|
||||
if (blank($ip)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hash_hmac('sha256', $ip, (string) Config::get('app.key'));
|
||||
}
|
||||
}
|
||||
131
app/Models/Call.php
Normal file
131
app/Models/Call.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CallStatus;
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use App\Models\Concerns\HasContentViews;
|
||||
use App\Models\Concerns\HasRevisions;
|
||||
use App\Models\Concerns\RecordsSlugChanges;
|
||||
use Database\Factories\CallFactory;
|
||||
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 Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* A call for contributors.
|
||||
*
|
||||
* The `hours_per_week` / `duration_weeks` pair is the point of this model. In
|
||||
* a hobby collective the scarce resource is time, not money: "~4h/week for 8
|
||||
* weeks" lets someone decide in ten seconds, where "we're looking for a
|
||||
* frontend dev" tells them nothing.
|
||||
*/
|
||||
#[Fillable([
|
||||
'project_id', 'title', 'slug', 'description', 'what_you_get', 'status',
|
||||
'hours_per_week', 'duration_weeks', 'seniority', 'opens_at', 'closes_at',
|
||||
])]
|
||||
class Call extends Model
|
||||
{
|
||||
/** @use HasFactory<CallFactory> */
|
||||
use BumpsContentVersion, HasContentViews, HasFactory, HasRevisions, HasTranslations, RecordsSlugChanges;
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $translatable = ['title', 'slug', 'description', 'what_you_get'];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
protected $attributes = [
|
||||
'status' => CallStatus::Open->value,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => CallStatus::class,
|
||||
'hours_per_week' => 'integer',
|
||||
'duration_weeks' => 'integer',
|
||||
'opens_at' => 'date',
|
||||
'closes_at' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `site.paths` key this model publishes under, so a slug change knows
|
||||
* which URL it just broke.
|
||||
*/
|
||||
public function publicPathType(): string
|
||||
{
|
||||
return 'call';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Technology, $this>
|
||||
*/
|
||||
public function technologies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Technology::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The people who answered this call.
|
||||
*
|
||||
* @return HasMany<Application, $this>
|
||||
*/
|
||||
public function applications(): HasMany
|
||||
{
|
||||
return $this->hasMany(Application::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open, within its window, and — when tied to a project — only while that
|
||||
* project is itself published.
|
||||
*
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeOpen(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('status', CallStatus::Open)
|
||||
->where(fn (Builder $q) => $q->whereNull('opens_at')->orWhere('opens_at', '<=', now()))
|
||||
->where(fn (Builder $q) => $q->whereNull('closes_at')->orWhere('closes_at', '>=', now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Total time being asked for, which is what a prospective contributor is
|
||||
* really weighing up.
|
||||
*/
|
||||
public function totalHours(): ?int
|
||||
{
|
||||
if ($this->hours_per_week === null || $this->duration_weeks === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hours_per_week * $this->duration_weeks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
59
app/Models/Category.php
Normal file
59
app/Models/Category.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use App\Models\Concerns\HasRevisions;
|
||||
use Database\Factories\CategoryFactory;
|
||||
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\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* @property array<string, string> $name
|
||||
* @property array<string, string> $slug
|
||||
*/
|
||||
#[Fillable(['name', 'slug', 'description', 'color', 'position', 'is_visible'])]
|
||||
class Category extends Model
|
||||
{
|
||||
/** @use HasFactory<CategoryFactory> */
|
||||
use BumpsContentVersion, HasFactory, HasRevisions, HasTranslations, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Columns stored as a locale => value JSON map.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $translatable = ['name', 'slug', 'description'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_visible' => 'boolean',
|
||||
'position' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Page, $this>
|
||||
*/
|
||||
public function pages(): HasMany
|
||||
{
|
||||
return $this->hasMany(Page::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_visible', true);
|
||||
}
|
||||
}
|
||||
90
app/Models/Client.php
Normal file
90
app/Models/Client.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use App\Models\Concerns\HasImages;
|
||||
use App\Models\Concerns\HasRevisions;
|
||||
use Database\Factories\ClientFactory;
|
||||
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\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* An organisation the work was for.
|
||||
*
|
||||
* This used to be a name typed into every project by hand, which meant the
|
||||
* same association written twice was two different entities and a logo had
|
||||
* nowhere to live. Being a record instead makes the wall of logos possible and
|
||||
* makes "everything we did for them" a question with an answer.
|
||||
*/
|
||||
#[Fillable(['name', 'slug', 'sector', 'logo', 'url', 'is_public', 'position'])]
|
||||
class Client extends Model
|
||||
{
|
||||
/** @use HasFactory<ClientFactory> */
|
||||
use BumpsContentVersion, HasFactory, HasImages, HasRevisions, HasTranslations, SoftDeletes;
|
||||
|
||||
/**
|
||||
* The name is a proper noun — an association is called what it is called
|
||||
* in every language — so only the prose about it is translated.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $translatable = ['sector'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_public' => 'boolean',
|
||||
'position' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function imageAttributes(): array
|
||||
{
|
||||
return ['logo'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Media, $this>
|
||||
*/
|
||||
public function logoMedia(): BelongsTo
|
||||
{
|
||||
return $this->imageRelation('logo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Project, $this>
|
||||
*/
|
||||
public function projects(): HasMany
|
||||
{
|
||||
return $this->hasMany(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Testimonial, $this>
|
||||
*/
|
||||
public function testimonials(): HasMany
|
||||
{
|
||||
return $this->hasMany(Testimonial::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
}
|
||||
}
|
||||
29
app/Models/Concerns/BumpsContentVersion.php
Normal file
29
app/Models/Concerns/BumpsContentVersion.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Support\ContentVersion;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Moves the content version on when something a visitor can see changes.
|
||||
*
|
||||
* Applied to the models the public API serves, so the frontend's cache can
|
||||
* expire the moment the site really changed rather than when a timer says so.
|
||||
*/
|
||||
trait BumpsContentVersion
|
||||
{
|
||||
public static function bootBumpsContentVersion(): void
|
||||
{
|
||||
$bump = function (Model $model): void {
|
||||
if (ContentVersion::shouldBumpFor($model)) {
|
||||
ContentVersion::bump();
|
||||
}
|
||||
};
|
||||
|
||||
static::saved($bump);
|
||||
static::deleted($bump);
|
||||
}
|
||||
}
|
||||
50
app/Models/Concerns/HasContentViews.php
Normal file
50
app/Models/Concerns/HasContentViews.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\ContentView;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
/**
|
||||
* A record the public site can be reading.
|
||||
*
|
||||
* The counts are a sum rather than a column on the record, so nothing has to
|
||||
* be kept in step and no write path can forget to increment anything. What
|
||||
* costs attention is reading them in a table, which is what the scope below is
|
||||
* for: one grouped query for the whole page of rows instead of one per row.
|
||||
*/
|
||||
trait HasContentViews
|
||||
{
|
||||
/**
|
||||
* @return MorphMany<ContentView, $this>
|
||||
*/
|
||||
public function views(): MorphMany
|
||||
{
|
||||
return $this->morphMany(ContentView::class, 'viewable');
|
||||
}
|
||||
|
||||
/**
|
||||
* How often this was read lately, in every language at once.
|
||||
*/
|
||||
public function viewsSince(int $days = 30): int
|
||||
{
|
||||
return (int) $this->views()->since($days)->sum('count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach that number to a query, for listings.
|
||||
*
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
public function scopeWithRecentViews(Builder $query, int $days = 30): Builder
|
||||
{
|
||||
return $query->withSum(
|
||||
['views as recent_views' => fn ($views) => $views->since($days)],
|
||||
'count',
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/Models/Concerns/HasImages.php
Normal file
54
app/Models/Concerns/HasImages.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\Media;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Keeps the media table in step with the image paths a record holds.
|
||||
*
|
||||
* The panel still uploads a file and stores its path, exactly as before; this
|
||||
* is what turns that path into something the frontend can render properly.
|
||||
* Models declare which of their attributes are images and expose an ordinary
|
||||
* `belongsTo` relation for each, so a listing can eager-load them instead of
|
||||
* asking once per row.
|
||||
*/
|
||||
trait HasImages
|
||||
{
|
||||
/**
|
||||
* Attributes holding the path of an uploaded file.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
abstract public function imageAttributes(): array;
|
||||
|
||||
public static function bootHasImages(): void
|
||||
{
|
||||
static::saved(function (Model $model): void {
|
||||
foreach ($model->imageAttributes() as $attribute) {
|
||||
// Only on the way in or on a change: an unrelated save must
|
||||
// not queue work for an image nobody touched.
|
||||
if ($model->wasChanged($attribute) || $model->wasRecentlyCreated) {
|
||||
Media::for($model->getAttribute($attribute));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation definition shared by every image attribute.
|
||||
*
|
||||
* The join is on the stored path rather than on an id, because the file is
|
||||
* the identity: two records pointing at one upload share its dimensions
|
||||
* and its derived versions instead of each deriving them again.
|
||||
*/
|
||||
protected function imageRelation(string $attribute): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Media::class, $attribute, 'path')
|
||||
->where('media.disk', (string) config('media.disk', 'public'));
|
||||
}
|
||||
}
|
||||
63
app/Models/Concerns/HasPublicationWindow.php
Normal file
63
app/Models/Concerns/HasPublicationWindow.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* A record that is only true between two dates.
|
||||
*
|
||||
* The window is enforced by each model's `published` scope, so nothing that
|
||||
* reads public content has to remember it. What lives here are the questions
|
||||
* the panel asks about that window — is this waiting for its date, has it
|
||||
* already passed — because a panel that shows "Published" for something the
|
||||
* site is not showing is lying to the person who wrote it.
|
||||
*/
|
||||
trait HasPublicationWindow
|
||||
{
|
||||
/**
|
||||
* Waiting for its date to arrive.
|
||||
*/
|
||||
public function isScheduled(): bool
|
||||
{
|
||||
return $this->published_at !== null && $this->published_at->isFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Past its end date, and therefore off the site.
|
||||
*/
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return $this->unpublish_at !== null && $this->unpublish_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* About to go, so it can be flagged before somebody notices it vanished.
|
||||
*/
|
||||
public function expiresSoon(int $days = 7): bool
|
||||
{
|
||||
return $this->unpublish_at !== null
|
||||
&& $this->unpublish_at->isFuture()
|
||||
&& $this->unpublish_at->lt(now()->addDays($days));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
public function scopeScheduled(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('published_at')->where('published_at', '>', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
public function scopeExpired(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('unpublish_at')->where('unpublish_at', '<=', now());
|
||||
}
|
||||
}
|
||||
252
app/Models/Concerns/HasReviews.php
Normal file
252
app/Models/Concerns/HasReviews.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Enums\ReviewEvent;
|
||||
use App\Models\ReviewComment;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ReviewRequested;
|
||||
use App\Notifications\ReviewReturned;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Handing work to somebody who is allowed to publish it.
|
||||
*
|
||||
* Used by the three models whose `publish` ability is a permission of its own.
|
||||
* That separation is the whole reason this exists: it means there are people
|
||||
* who can finish a piece of work and cannot put it on the site, and until now
|
||||
* they had nowhere to say they were done — the status field that would have
|
||||
* said it is disabled for precisely those users.
|
||||
*
|
||||
* `review_requested_at` is the state, and it is the only state: a review is
|
||||
* either waiting for somebody or it is not. Pages also carry a `Review` value
|
||||
* in their status enum, which predates this and is shown in tables and
|
||||
* filters, so `syncReviewStatus()` keeps it in step — the write happens here
|
||||
* and only here, so the two cannot drift.
|
||||
*/
|
||||
trait HasReviews
|
||||
{
|
||||
/**
|
||||
* These change as a side effect of asking and answering, and the thread
|
||||
* already records both in words. Leaving them in the change history would
|
||||
* bury the edits somebody actually came looking for.
|
||||
*/
|
||||
public function initializeHasReviews(): void
|
||||
{
|
||||
// Merged rather than declared on each model: three copies of the same
|
||||
// cast is three chances for one of them to be forgotten, and the
|
||||
// symptom — a string where a date was expected — only appears in
|
||||
// whichever screen reads it first.
|
||||
$this->mergeCasts(['review_requested_at' => 'datetime']);
|
||||
|
||||
$this->revisionIgnores = [
|
||||
...$this->revisionIgnores,
|
||||
'review_requested_at', 'review_requested_by', 'reviewer_id',
|
||||
];
|
||||
}
|
||||
|
||||
public static function bootHasReviews(): void
|
||||
{
|
||||
static::updated(function (Model $model): void {
|
||||
// Publishing is the answer a review was waiting for, whichever
|
||||
// route it arrived by — the form, the table's publish button, the
|
||||
// API. Hooked on the state rather than on the button so none of
|
||||
// them can leave a record live and still marked as waiting.
|
||||
if ($model->isAwaitingReview() && $model->isPublishedNow()) {
|
||||
$model->closeReview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphMany<ReviewComment, $this>
|
||||
*/
|
||||
public function reviewComments(): MorphMany
|
||||
{
|
||||
// By id as well as by time: two entries of the same thread routinely
|
||||
// land in the same second, and a timestamp alone leaves the order up
|
||||
// to the database.
|
||||
return $this->morphMany(ReviewComment::class, 'reviewable')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function reviewRequester(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'review_requested_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function reviewer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reviewer_id');
|
||||
}
|
||||
|
||||
public function isAwaitingReview(): bool
|
||||
{
|
||||
return $this->review_requested_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a look. The reviewer is optional: naming one addresses the
|
||||
* request, leaving it empty offers it to everybody who could publish.
|
||||
*/
|
||||
public function requestReview(?User $reviewer = null, ?string $note = null, ?User $by = null): void
|
||||
{
|
||||
$by ??= Auth::user();
|
||||
|
||||
$this->forceFill([
|
||||
'review_requested_at' => now(),
|
||||
'review_requested_by' => $by?->getAuthIdentifier(),
|
||||
'reviewer_id' => $reviewer?->getAuthIdentifier(),
|
||||
]);
|
||||
|
||||
$this->syncReviewStatus(true);
|
||||
|
||||
$this->save();
|
||||
|
||||
$this->recordReviewEvent(ReviewEvent::Requested, $note, $by);
|
||||
|
||||
$this->announce(
|
||||
ReviewRequested::recipients($this),
|
||||
new ReviewRequested($this, $note, $by?->name),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send it back, with a reason. The reason is not optional anywhere this is
|
||||
* called from: work returned without one is the thing that makes a review
|
||||
* step resented rather than used.
|
||||
*/
|
||||
public function returnForChanges(string $note, ?User $by = null): void
|
||||
{
|
||||
$by ??= Auth::user();
|
||||
$author = $this->reviewRequester()->first();
|
||||
|
||||
$this->forceFill([
|
||||
'review_requested_at' => null,
|
||||
'reviewer_id' => null,
|
||||
]);
|
||||
|
||||
$this->syncReviewStatus(false);
|
||||
|
||||
$this->save();
|
||||
|
||||
$this->recordReviewEvent(ReviewEvent::Returned, $note, $by);
|
||||
|
||||
// Told to the person who asked, and not to whoever else could have
|
||||
// reviewed it: a queue that announces every decision to everybody
|
||||
// becomes a bell people stop reading.
|
||||
if ($author !== null && $author->isNot($by)) {
|
||||
$this->announce(collect([$author]), new ReviewReturned($this, $note, $by?->name));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The review is over because the thing is live.
|
||||
*/
|
||||
public function closeReview(?User $by = null): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'review_requested_at' => null,
|
||||
'reviewer_id' => null,
|
||||
]);
|
||||
|
||||
$this->saveQuietly();
|
||||
|
||||
$this->recordReviewEvent(ReviewEvent::Published, null, $by ?? Auth::user());
|
||||
}
|
||||
|
||||
public function comment(string $body, ?User $by = null): ReviewComment
|
||||
{
|
||||
return $this->recordReviewEvent(ReviewEvent::Commented, $body, $by ?? Auth::user());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the people who need to know, without letting that failure undo
|
||||
* anything. The state change is already saved and is the part that
|
||||
* matters; a bell that did not ring is a smaller problem than a handover
|
||||
* that silently did not happen.
|
||||
*
|
||||
* @param Collection<int, User> $recipients
|
||||
*/
|
||||
protected function announce(Collection $recipients, Notification $notification): void
|
||||
{
|
||||
if ($recipients->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationFacade::send($recipients, $notification);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
protected function recordReviewEvent(ReviewEvent $event, ?string $body, ?User $by): ReviewComment
|
||||
{
|
||||
return $this->reviewComments()->create([
|
||||
'user_id' => $by?->getAuthIdentifier(),
|
||||
|
||||
// Kept beside the id because people leave and accounts get
|
||||
// deleted, and "somebody sent this back" is worth less than a name.
|
||||
'user_name' => $by?->name,
|
||||
|
||||
'event' => $event,
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the review onto a model that also carries a status enum.
|
||||
*
|
||||
* Overridden by pages, whose status has said `Review` since before any of
|
||||
* this existed. Projects and services have no such value — a project's
|
||||
* status is its lifecycle, not its visibility — so for them this is
|
||||
* nothing, which is the right answer rather than a missing feature.
|
||||
*/
|
||||
protected function syncReviewStatus(bool $awaiting): void {}
|
||||
|
||||
/**
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
public function scopeAwaitingReview(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('review_requested_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Waiting for this person specifically, or waiting for anybody.
|
||||
*
|
||||
* An unaddressed request is everybody's, which is deliberate: a queue
|
||||
* addressed to nobody is still better than one addressed to whoever the
|
||||
* author guessed was free.
|
||||
*
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
public function scopeAwaitingReviewBy(Builder $query, User $user): Builder
|
||||
{
|
||||
return $query->awaitingReview()->where(
|
||||
fn (Builder $scope) => $scope
|
||||
->whereNull('reviewer_id')
|
||||
->orWhere('reviewer_id', $user->getAuthIdentifier()),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
app/Models/Concerns/HasRevisions.php
Normal file
145
app/Models/Concerns/HasRevisions.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\Revision;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Records who changed what.
|
||||
*
|
||||
* Only what actually changed is kept, old value and new. Storing whole records
|
||||
* would make the history larger than the content it describes, and reading a
|
||||
* diff is the point — "María changed the Catalan title" is useful, "María
|
||||
* saved the page" is not.
|
||||
*/
|
||||
trait HasRevisions
|
||||
{
|
||||
/**
|
||||
* Attributes never worth recording: they change on every save and say
|
||||
* nothing about what anybody did.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $revisionIgnores = ['updated_at', 'created_at', 'remember_token'];
|
||||
|
||||
public static function bootHasRevisions(): void
|
||||
{
|
||||
static::created(fn (Model $model) => $model->recordRevision('created'));
|
||||
|
||||
static::updated(function (Model $model): void {
|
||||
$changes = $model->revisionChanges();
|
||||
|
||||
if ($changes === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Publishing gets its own name in the history. It is a separate
|
||||
// permission because it is a bigger act than editing, and a list
|
||||
// where it reads the same as any other change hides exactly the
|
||||
// thing somebody came looking for.
|
||||
$model->recordRevision($model->revisionEvent($changes), $changes);
|
||||
});
|
||||
|
||||
static::deleted(fn (Model $model) => $model->recordRevision(
|
||||
method_exists($model, 'isForceDeleting') && $model->isForceDeleting() ? 'erased' : 'deleted',
|
||||
));
|
||||
|
||||
if (method_exists(static::class, 'restored')) {
|
||||
static::restored(fn (Model $model) => $model->recordRevision('restored'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphMany<Revision, $this>
|
||||
*/
|
||||
public function revisions(): MorphMany
|
||||
{
|
||||
// Ordered by id as well as by time: two revisions of the same
|
||||
// record routinely land in the same second — create, then edit — and
|
||||
// a timestamp alone leaves "latest" up to the database. SQLite and
|
||||
// Postgres disagreed, which is how this was found.
|
||||
return $this->morphMany(Revision::class, 'revisionable')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $changes
|
||||
*/
|
||||
public function recordRevision(string $event, ?array $changes = null): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$this->revisions()->create([
|
||||
'user_id' => $user?->getAuthIdentifier(),
|
||||
|
||||
// Kept alongside the id because people leave and accounts get
|
||||
// deleted, and "somebody published this" is worth less than a name.
|
||||
'user_name' => $user?->name,
|
||||
|
||||
'event' => $event,
|
||||
'changeset' => $changes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* What changed on this save, old value beside new.
|
||||
*
|
||||
* @return array<string, array{old: mixed, new: mixed}>
|
||||
*/
|
||||
public function revisionChanges(): array
|
||||
{
|
||||
$changes = [];
|
||||
|
||||
foreach ($this->getChanges() as $attribute => $new) {
|
||||
if (in_array($attribute, $this->revisionIgnores, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$old = $this->getOriginal($attribute);
|
||||
|
||||
// Casts turn these into objects and enums; the history is read as
|
||||
// text, and JSON is the only shape both ends agree on.
|
||||
$changes[$attribute] = [
|
||||
'old' => $this->revisionValue($old),
|
||||
'new' => $this->revisionValue($new),
|
||||
];
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{old: mixed, new: mixed}> $changes
|
||||
*/
|
||||
protected function revisionEvent(array $changes): string
|
||||
{
|
||||
if (! array_key_exists('published_at', $changes) && ! array_key_exists('status', $changes)) {
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
// Ask the model itself: "published" means different things to a page
|
||||
// with a status and a project with only a date.
|
||||
if (method_exists($this, 'isPublishedNow')) {
|
||||
return $this->isPublishedNow() ? 'published' : 'unpublished';
|
||||
}
|
||||
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
protected function revisionValue(mixed $value): mixed
|
||||
{
|
||||
return match (true) {
|
||||
$value instanceof \BackedEnum => $value->value,
|
||||
$value instanceof \DateTimeInterface => $value->format('c'),
|
||||
is_object($value) => json_decode((string) json_encode($value), true),
|
||||
is_string($value) && str_starts_with($value, '{') => json_decode($value, true) ?? $value,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
}
|
||||
93
app/Models/Concerns/RecordsSlugChanges.php
Normal file
93
app/Models/Concerns/RecordsSlugChanges.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\Redirect;
|
||||
use App\Support\SiteUrls;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Leaves a redirect behind when a slug changes.
|
||||
*
|
||||
* Renaming a slug breaks every link anybody shared, every bookmark and every
|
||||
* search result — silently, because nothing notices and the frontend only
|
||||
* finds out when a visitor gets a 404. The moment the slug changes is the only
|
||||
* moment the old address is still known, so that is when the note is written.
|
||||
*/
|
||||
trait RecordsSlugChanges
|
||||
{
|
||||
/**
|
||||
* The `site.paths` key this model publishes under.
|
||||
*/
|
||||
abstract public function publicPathType(): string;
|
||||
|
||||
public static function bootRecordsSlugChanges(): void
|
||||
{
|
||||
static::updated(function (Model $model): void {
|
||||
if (! $model->wasChanged('slug')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$model->recordSlugChange();
|
||||
});
|
||||
}
|
||||
|
||||
protected function recordSlugChange(): void
|
||||
{
|
||||
$type = $this->publicPathType();
|
||||
$previous = (array) json_decode((string) json_encode($this->getOriginal('slug')), true);
|
||||
$current = (array) $this->getTranslations('slug');
|
||||
|
||||
foreach (array_keys((array) config('locales.supported')) as $locale) {
|
||||
$was = (string) ($previous[$locale] ?? '');
|
||||
$now = (string) ($current[$locale] ?? '');
|
||||
|
||||
// Nothing to redirect from when the record had no address in this
|
||||
// language, and nowhere to redirect to when it no longer has one.
|
||||
if (blank($was) || blank($now) || $was === $now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$from = Redirect::normalise(SiteUrls::path($type, $locale, $was));
|
||||
$to = Redirect::normalise(SiteUrls::path($type, $locale, $now));
|
||||
|
||||
if ($from === $to) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->writeRedirect($from, $to, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
protected function writeRedirect(string $from, string $to, string $locale): void
|
||||
{
|
||||
$scope = fn () => Redirect::query()->where('locale', $locale);
|
||||
|
||||
// The new address resolves to this record again, so any rule pointing
|
||||
// away from it is now wrong — this is what renaming something back to
|
||||
// its original slug looks like.
|
||||
$scope()->where('from', $to)->delete();
|
||||
|
||||
// Whoever pointed at the old address now points at the new one, so a
|
||||
// frontend never has to follow two hops.
|
||||
$scope()->where('to', $from)->update(['to' => $to]);
|
||||
|
||||
$redirect = Redirect::query()->firstOrNew(['from' => $from, 'locale' => $locale]);
|
||||
|
||||
// `source` is set here rather than through mass assignment: it records
|
||||
// what the application did, not anything somebody submitted.
|
||||
$redirect->forceFill([
|
||||
'to' => $to,
|
||||
'status' => 301,
|
||||
'is_enabled' => true,
|
||||
'source_type' => $this->getMorphClass(),
|
||||
'source_id' => $this->getKey(),
|
||||
])->save();
|
||||
|
||||
// Belt and braces: a rule pointing at its own address would send a
|
||||
// browser round for ever.
|
||||
Redirect::query()->whereColumn('from', 'to')->delete();
|
||||
}
|
||||
}
|
||||
77
app/Models/ContentView.php
Normal file
77
app/Models/ContentView.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* One record, one language, one day, one number.
|
||||
*
|
||||
* Everything the panel can say about being read is a sum over these. There is
|
||||
* nothing else in the row on purpose: a table that also knew the address, the
|
||||
* hour and the browser would be a visitor log, and this exists to answer
|
||||
* editorial questions rather than to watch anybody.
|
||||
*/
|
||||
#[Fillable(['locale', 'day', 'count'])]
|
||||
class ContentView extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'day' => 'date',
|
||||
'count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function viewable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to today's tally for one record in one language.
|
||||
*
|
||||
* An upsert for the same reason the 404 counter uses one: these arrive in
|
||||
* bursts, and two of them racing on the same day would either violate the
|
||||
* unique index or lose a count.
|
||||
*/
|
||||
public static function record(Model $viewable, string $locale, int $count = 1): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
static::query()->upsert(
|
||||
[[
|
||||
'viewable_type' => $viewable->getMorphClass(),
|
||||
'viewable_id' => $viewable->getKey(),
|
||||
'locale' => $locale,
|
||||
'day' => $now->toDateString(),
|
||||
'count' => $count,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]],
|
||||
['viewable_type', 'viewable_id', 'locale', 'day'],
|
||||
[
|
||||
'count' => DB::raw("content_views.count + {$count}"),
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeSince(Builder $query, int $days): Builder
|
||||
{
|
||||
return $query->where('day', '>=', now()->subDays($days)->toDateString());
|
||||
}
|
||||
}
|
||||
72
app/Models/ImageAttachment.php
Normal file
72
app/Models/ImageAttachment.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use App\Models\Concerns\HasImages;
|
||||
use Database\Factories\ImageAttachmentFactory;
|
||||
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\MorphTo;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* One image in a record's gallery.
|
||||
*
|
||||
* Carries what a gallery needs and a single cover never did: an order, a
|
||||
* description, and a caption — which is a different thing from a description.
|
||||
* The alt text is for whoever cannot see the image; the caption is shown to
|
||||
* everybody, and is where "before" and "after" belong.
|
||||
*/
|
||||
#[Fillable(['collection', 'path', 'alt', 'caption', 'position'])]
|
||||
class ImageAttachment extends Model
|
||||
{
|
||||
/** @use HasFactory<ImageAttachmentFactory> */
|
||||
use BumpsContentVersion, HasFactory, HasImages, HasTranslations;
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $translatable = ['alt', 'caption'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['position' => 'integer'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function imageAttributes(): array
|
||||
{
|
||||
return ['path'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function attachable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Media, $this>
|
||||
*/
|
||||
public function media(): BelongsTo
|
||||
{
|
||||
return $this->imageRelation('path');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('position')->orderBy('id');
|
||||
}
|
||||
}
|
||||
244
app/Models/Media.php
Normal file
244
app/Models/Media.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Jobs\ProcessMedia;
|
||||
use Database\Factories\MediaFactory;
|
||||
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\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* An uploaded file and everything derived from it.
|
||||
*
|
||||
* Identified by (disk, path) rather than by an id chosen here, so the record
|
||||
* follows the file: uploading the same image twice, or pointing two models at
|
||||
* one upload, converges on a single row and a single set of derived versions.
|
||||
*
|
||||
* Nothing here is authored. If the table were dropped it could be rebuilt from
|
||||
* the files with `php artisan media:process --all`.
|
||||
*/
|
||||
#[Fillable(['disk', 'path', 'mime', 'size', 'width', 'height', 'placeholder', 'variants', 'processed_at', 'error'])]
|
||||
class Media extends Model
|
||||
{
|
||||
/** @use HasFactory<MediaFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'media';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'size' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'variants' => 'array',
|
||||
'processed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The row for a stored path, creating and queueing it the first time.
|
||||
*
|
||||
* Returns null for a blank path so callers can hand it whatever an
|
||||
* optional image field holds without checking first.
|
||||
*/
|
||||
public static function for(?string $path, ?string $disk = null): ?self
|
||||
{
|
||||
if (blank($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk ??= (string) config('media.disk', 'public');
|
||||
|
||||
$media = static::query()->firstOrCreate(
|
||||
['disk' => $disk, 'path' => ltrim($path, '/')],
|
||||
);
|
||||
|
||||
if ($media->wasRecentlyCreated) {
|
||||
ProcessMedia::dispatch($media);
|
||||
}
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL. The frontend runs on another host, so a path only this
|
||||
* application can resolve would be no use to it.
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return static::absolute(Storage::disk($this->disk)->url($this->path));
|
||||
}
|
||||
|
||||
public function exists(): bool
|
||||
{
|
||||
return Storage::disk($this->disk)->exists($this->path);
|
||||
}
|
||||
|
||||
public function isProcessed(): bool
|
||||
{
|
||||
return $this->processed_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aspect ratio, for reserving the right box before the image arrives.
|
||||
*/
|
||||
public function aspectRatio(): ?float
|
||||
{
|
||||
if (! $this->width || ! $this->height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round($this->width / $this->height, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived versions grouped by format, widest last.
|
||||
*
|
||||
* @return array<string, array<int, array{width: int, url: string}>>
|
||||
*/
|
||||
public function variantsByFormat(): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($this->variants ?? [] as $variant) {
|
||||
$grouped[$variant['format']][] = [
|
||||
'width' => (int) $variant['width'],
|
||||
'url' => static::absolute(Storage::disk($this->disk)->url($variant['path'])),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($grouped as $format => $variants) {
|
||||
usort($variants, fn (array $a, array $b) => $a['width'] <=> $b['width']);
|
||||
$grouped[$format] = $variants;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready-to-use `srcset` string per format.
|
||||
*
|
||||
* Built here rather than in the frontend because only this side knows
|
||||
* which widths actually exist — and offering one that does not would put
|
||||
* a broken image on the page.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function srcset(): array
|
||||
{
|
||||
$sets = [];
|
||||
|
||||
foreach ($this->variantsByFormat() as $format => $variants) {
|
||||
$sets[$format] = collect($variants)
|
||||
->map(fn (array $v) => "{$v['url']} {$v['width']}w")
|
||||
->implode(', ');
|
||||
}
|
||||
|
||||
return $sets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file this record owns, original included. Used when reprocessing,
|
||||
* so a second run does not leave the first run's versions behind.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function derivedPaths(): array
|
||||
{
|
||||
return collect($this->variants ?? [])->pluck('path')->all();
|
||||
}
|
||||
|
||||
public function deleteDerived(): void
|
||||
{
|
||||
$paths = $this->derivedPaths();
|
||||
|
||||
if ($paths !== []) {
|
||||
Storage::disk($this->disk)->delete($paths);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every path a record currently points at.
|
||||
*
|
||||
* The answer to "is anything still using this file?", which is the only
|
||||
* question that makes deleting one safe.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function referencedPaths(): array
|
||||
{
|
||||
$paths = [];
|
||||
|
||||
$sources = [
|
||||
[Page::class, 'featured_image'],
|
||||
[Project::class, 'cover_image'],
|
||||
[Member::class, 'avatar'],
|
||||
[Client::class, 'logo'],
|
||||
[Testimonial::class, 'avatar'],
|
||||
[ImageAttachment::class, 'path'],
|
||||
];
|
||||
|
||||
foreach ($sources as [$model, $column]) {
|
||||
$query = $model::query()->whereNotNull($column);
|
||||
|
||||
// Something in the bin can still be restored, so its images are
|
||||
// still in use — deleting them would restore a broken record.
|
||||
if (in_array(SoftDeletes::class, class_uses_recursive($model), true)) {
|
||||
$query->withTrashed();
|
||||
}
|
||||
|
||||
$paths = [...$paths, ...$query->pluck($column)->all()];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($paths)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any record still points at this file.
|
||||
*
|
||||
* The question that decides whether deleting it is safe, and the reason
|
||||
* the panel refuses to delete one that is in use.
|
||||
*/
|
||||
public function isReferenced(): bool
|
||||
{
|
||||
return in_array($this->path, static::referencedPaths(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total bytes on disk, original and every derived version.
|
||||
*/
|
||||
public function totalSize(): int
|
||||
{
|
||||
return (int) $this->size + collect($this->variants ?? [])->sum('size');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('processed_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disks configured with a relative URL (the `local` driver's own route,
|
||||
* for instance) would hand the frontend a link it cannot resolve.
|
||||
*/
|
||||
protected static function absolute(string $url): string
|
||||
{
|
||||
if (Str::startsWith($url, ['http://', 'https://'])) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return rtrim((string) config('app.url'), '/').'/'.ltrim($url, '/');
|
||||
}
|
||||
}
|
||||
144
app/Models/Member.php
Normal file
144
app/Models/Member.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use App\Models\Concerns\HasContentViews;
|
||||
use App\Models\Concerns\HasImages;
|
||||
use App\Models\Concerns\HasRevisions;
|
||||
use Database\Factories\MemberFactory;
|
||||
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\SoftDeletes;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* Someone in the collective.
|
||||
*
|
||||
* A member is not a panel user: most people who appear on the site never need
|
||||
* admin access. `user_id` links the two only when they do, so removing
|
||||
* someone's admin rights never removes them from the website.
|
||||
*/
|
||||
#[Fillable([
|
||||
'user_id', 'name', 'slug', 'headline', 'bio', 'avatar', 'email',
|
||||
'github_username', 'links', 'is_public', 'is_founder',
|
||||
'joined_at', 'left_at', 'position',
|
||||
])]
|
||||
class Member extends Model
|
||||
{
|
||||
/** @use HasFactory<MemberFactory> */
|
||||
use BumpsContentVersion, HasContentViews, HasFactory, HasImages, HasRevisions, HasTranslations, SoftDeletes;
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $translatable = ['headline', 'bio'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'links' => 'array',
|
||||
'is_public' => 'boolean',
|
||||
'is_founder' => 'boolean',
|
||||
'joined_at' => 'date',
|
||||
'left_at' => 'date',
|
||||
'position' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function imageAttributes(): array
|
||||
{
|
||||
return ['avatar'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Media, $this>
|
||||
*/
|
||||
public function avatarMedia(): BelongsTo
|
||||
{
|
||||
return $this->imageRelation('avatar');
|
||||
}
|
||||
|
||||
/**
|
||||
* A portrait's description is the person's name, so there is no separate
|
||||
* field for it: one that had to be typed would only ever be typed wrong.
|
||||
*/
|
||||
public function avatarAlt(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Technology, $this>
|
||||
*/
|
||||
public function technologies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Technology::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Project, $this>
|
||||
*/
|
||||
public function projects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Project::class)->withPivot('role');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Talk, $this>
|
||||
*/
|
||||
public function talks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Talk::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Project, $this>
|
||||
*/
|
||||
public function ledProjects(): HasMany
|
||||
{
|
||||
return $this->hasMany(Project::class, 'lead_member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Someone who has not left the collective.
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->left_at === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeCurrent(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('left_at');
|
||||
}
|
||||
}
|
||||
154
app/Models/MissingUrl.php
Normal file
154
app/Models/MissingUrl.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\MissingUrlStatus;
|
||||
use Database\Factories\MissingUrlFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* An address the frontend was asked for and could not resolve.
|
||||
*
|
||||
* Nobody creates one of these by hand: they arrive from the frontend, which is
|
||||
* the only place that finds out. What the panel does with them is decide which
|
||||
* ones deserve a redirect.
|
||||
*/
|
||||
#[Fillable(['path', 'locale', 'referrer', 'status'])]
|
||||
class MissingUrl extends Model
|
||||
{
|
||||
/** @use HasFactory<MissingUrlFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $attributes = [
|
||||
'status' => MissingUrlStatus::Pending->value,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => MissingUrlStatus::class,
|
||||
'hits' => 'integer',
|
||||
'first_seen_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one sighting of a path.
|
||||
*
|
||||
* The count is an upsert rather than a read-then-write because these
|
||||
* arrive in bursts — a crawler walking a dead section produces dozens in a
|
||||
* second — and two of them racing on the same path would otherwise violate
|
||||
* the unique index or lose a count. What an upsert cannot express portably
|
||||
* follows it as two narrow updates on the same indexed row.
|
||||
*
|
||||
* A *resolved* row comes back to `pending` when it is seen again: the
|
||||
* status was a claim that the address had a destination, and a fresh 404
|
||||
* disproves it — a redirect written and then deleted is exactly this.
|
||||
*
|
||||
* An *ignored* row does not. That status is a decision about attention
|
||||
* rather than a claim about the world, and the addresses it is used on are
|
||||
* the ones being requested constantly. Reviving them on the next sighting
|
||||
* would mean the button never worked twice.
|
||||
*/
|
||||
public static function report(string $path, string $locale, ?string $referrer = null): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
static::query()->upsert(
|
||||
[[
|
||||
'path' => $path,
|
||||
'locale' => $locale,
|
||||
'referrer' => $referrer,
|
||||
'status' => MissingUrlStatus::Pending->value,
|
||||
'hits' => 1,
|
||||
'first_seen_at' => $now,
|
||||
'last_seen_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]],
|
||||
['path', 'locale'],
|
||||
[
|
||||
'hits' => DB::raw('missing_urls.hits + 1'),
|
||||
'last_seen_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
|
||||
// Kept out of the upsert because it must apply to one status and not
|
||||
// the other, which no portable upsert can express.
|
||||
static::query()
|
||||
->where('path', $path)
|
||||
->where('locale', $locale)
|
||||
->where('status', MissingUrlStatus::Resolved)
|
||||
->update([
|
||||
'status' => MissingUrlStatus::Pending,
|
||||
'resolved_at' => null,
|
||||
]);
|
||||
|
||||
// Kept out of the upsert on purpose. Overwriting unconditionally would
|
||||
// erase the referrer of a known bad link the moment the same address
|
||||
// is next requested without one — which is what a bot does. So the
|
||||
// newest referrer wins, but only against another referrer.
|
||||
if (filled($referrer)) {
|
||||
static::query()
|
||||
->where('path', $path)
|
||||
->where('locale', $locale)
|
||||
->update(['referrer' => $referrer]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark whatever this redirect now answers as dealt with.
|
||||
*
|
||||
* Called when a redirect is written, from either end — the panel's action
|
||||
* on this list, a rule typed by hand, or a slug rename — so the list never
|
||||
* shows an address that has already been given a destination.
|
||||
*/
|
||||
public static function resolvedBy(Redirect $redirect): void
|
||||
{
|
||||
static::query()
|
||||
->where('path', $redirect->from)
|
||||
->where('status', '!=', MissingUrlStatus::Resolved)
|
||||
// A redirect with no locale applies in every language, so it
|
||||
// answers the sightings of that path in all of them.
|
||||
->when($redirect->locale !== null, fn (Builder $query) => $query
|
||||
->where('locale', $redirect->locale))
|
||||
->update([
|
||||
'status' => MissingUrlStatus::Resolved,
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the link that produced this came from our own site.
|
||||
*
|
||||
* The distinction that decides how urgent it is: an external referrer is
|
||||
* somebody else's stale link, and a redirect is a courtesy. Our own domain
|
||||
* means a page we publish is pointing at an address that does not exist.
|
||||
*/
|
||||
public function isInternalReferrer(): bool
|
||||
{
|
||||
$site = (string) config('site.url');
|
||||
|
||||
return filled($this->referrer) && filled($site) && Str::startsWith($this->referrer, $site);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', MissingUrlStatus::Pending);
|
||||
}
|
||||
}
|
||||
197
app/Models/Page.php
Normal file
197
app/Models/Page.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?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}", '!=', '');
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
112
app/Models/Redirect.php
Normal file
112
app/Models/Redirect.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use Database\Factories\RedirectFactory;
|
||||
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\MorphTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* A URL that used to work, and where it goes now.
|
||||
*
|
||||
* Most of these are never typed by anybody: renaming a slug creates one, which
|
||||
* is the only moment at which the old address is still known.
|
||||
*/
|
||||
#[Fillable(['from', 'to', 'status', 'locale', 'is_enabled'])]
|
||||
class Redirect extends Model
|
||||
{
|
||||
/** @use HasFactory<RedirectFactory> */
|
||||
use BumpsContentVersion, HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'integer',
|
||||
'is_enabled' => 'boolean',
|
||||
'hits' => 'integer',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// A reported 404 that now has a destination is dealt with, however the
|
||||
// rule came to exist: written from the list of missing URLs, typed by
|
||||
// hand, or left behind by a slug rename. Hooked here rather than in
|
||||
// the panel action so no route can create a redirect and leave the
|
||||
// address it answers still sitting in the list of things to do.
|
||||
static::created(fn (Redirect $redirect) => MissingUrl::resolvedBy($redirect));
|
||||
|
||||
static::updated(function (Redirect $redirect): void {
|
||||
if ($redirect->wasChanged(['from', 'locale'])) {
|
||||
MissingUrl::resolvedBy($redirect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What changed its address, when a record did.
|
||||
*
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function source(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function wasAutomatic(): bool
|
||||
{
|
||||
return $this->source_type !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading slash, no trailing one, no query string: the shape the frontend
|
||||
* compares against, normalised on the way in so a rule typed by hand
|
||||
* matches one created automatically.
|
||||
*/
|
||||
public static function normalise(string $path): string
|
||||
{
|
||||
$path = trim(parse_url($path, PHP_URL_PATH) ?: $path);
|
||||
$path = '/'.trim($path, '/');
|
||||
|
||||
return $path === '/' ? '/' : rtrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeEnabled(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a use, cheaply.
|
||||
*
|
||||
* Worth having because it answers the question that decides whether a
|
||||
* redirect can ever be removed: is anybody still following it?
|
||||
*/
|
||||
public function markUsed(): void
|
||||
{
|
||||
$this->newQuery()->whereKey($this->getKey())->update([
|
||||
'hits' => $this->hits + 1,
|
||||
'last_used_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this rule would send a visitor round in circles.
|
||||
*/
|
||||
public function isCircular(): bool
|
||||
{
|
||||
return Str::lower($this->from) === Str::lower($this->to);
|
||||
}
|
||||
}
|
||||
136
app/Models/Repository.php
Normal file
136
app/Models/Repository.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\GitProvider;
|
||||
use Database\Factories\RepositoryFactory;
|
||||
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\HasMany;
|
||||
|
||||
/**
|
||||
* A git repository belonging to the collective.
|
||||
*
|
||||
* Only `provider`, `full_name`, `project_id` and the two visibility flags are
|
||||
* entered by hand. Everything else is a cache of what the provider reported,
|
||||
* refreshed by App\Jobs\SyncRepository — which is the whole point: the site
|
||||
* shows real activity without anyone updating it.
|
||||
*/
|
||||
#[Fillable([
|
||||
'project_id', 'provider', 'full_name', 'is_primary', 'show_publicly',
|
||||
])]
|
||||
class Repository extends Model
|
||||
{
|
||||
/** @use HasFactory<RepositoryFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* Columns owned by the sync process rather than by a human. Kept out of
|
||||
* the fillable list above so a stray form submission cannot fake stats.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const SYNCED_COLUMNS = [
|
||||
'html_url', 'description', 'homepage', 'default_branch',
|
||||
'primary_language', 'license', 'topics', 'languages', 'stars',
|
||||
'forks', 'watchers', 'open_issues_count', 'contributors_count',
|
||||
'commits_last_year', 'is_archived', 'is_fork', 'pushed_at',
|
||||
'repo_created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'provider' => GitProvider::class,
|
||||
'topics' => 'array',
|
||||
'languages' => 'array',
|
||||
'is_primary' => 'boolean',
|
||||
'show_publicly' => 'boolean',
|
||||
'is_archived' => 'boolean',
|
||||
'is_fork' => 'boolean',
|
||||
'pushed_at' => 'datetime',
|
||||
'repo_created_at' => 'datetime',
|
||||
'last_synced_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<RepositoryIssue, $this>
|
||||
*/
|
||||
public function issues(): HasMany
|
||||
{
|
||||
return $this->hasMany(RepositoryIssue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues suitable for someone who has never contributed before.
|
||||
*
|
||||
* @return HasMany<RepositoryIssue, $this>
|
||||
*/
|
||||
public function goodFirstIssues(): HasMany
|
||||
{
|
||||
return $this->issues()->where('is_good_first_issue', true);
|
||||
}
|
||||
|
||||
public function owner(): string
|
||||
{
|
||||
return str_contains($this->full_name, '/')
|
||||
? explode('/', $this->full_name)[0]
|
||||
: $this->full_name;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return str_contains($this->full_name, '/')
|
||||
? explode('/', $this->full_name, 2)[1]
|
||||
: $this->full_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* A repository is considered dormant when nothing has been pushed for a
|
||||
* while. The public site uses this to avoid advertising dead work as live.
|
||||
*/
|
||||
public function isDormant(int $days = 180): bool
|
||||
{
|
||||
return $this->pushed_at === null || $this->pushed_at->lt(now()->subDays($days));
|
||||
}
|
||||
|
||||
public function hasSyncedSuccessfully(): bool
|
||||
{
|
||||
return $this->last_synced_at !== null && blank($this->sync_error);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePubliclyVisible(Builder $query): Builder
|
||||
{
|
||||
return $query->where('show_publicly', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repositories whose sync keeps failing, so the panel can flag them rather
|
||||
* than silently showing stale numbers forever.
|
||||
*
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeFailing(Builder $query): Builder
|
||||
{
|
||||
return $query->where('sync_failures', '>=', 3);
|
||||
}
|
||||
}
|
||||
61
app/Models/RepositoryIssue.php
Normal file
61
app/Models/RepositoryIssue.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* An open issue mirrored from a repository.
|
||||
*
|
||||
* Only issues worth showing a newcomer are kept — the ones labelled as good
|
||||
* first issues or help wanted. Mirroring every open issue would be noise.
|
||||
*/
|
||||
#[Fillable([
|
||||
'repository_id', 'number', 'title', 'html_url', 'labels',
|
||||
'is_good_first_issue', 'is_help_wanted', 'author', 'assignee',
|
||||
'comments', 'issue_created_at', 'issue_updated_at',
|
||||
])]
|
||||
class RepositoryIssue extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'labels' => 'array',
|
||||
'is_good_first_issue' => 'boolean',
|
||||
'is_help_wanted' => 'boolean',
|
||||
'comments' => 'integer',
|
||||
'issue_created_at' => 'datetime',
|
||||
'issue_updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Repository, $this>
|
||||
*/
|
||||
public function repository(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Repository::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nobody has picked it up yet, so it is genuinely available.
|
||||
*/
|
||||
public function isUnclaimed(): bool
|
||||
{
|
||||
return blank($this->assignee);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeAvailable(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('assignee');
|
||||
}
|
||||
}
|
||||
57
app/Models/ReviewComment.php
Normal file
57
app/Models/ReviewComment.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ReviewEvent;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* One thing somebody said while a record was being reviewed.
|
||||
*
|
||||
* Sits beside the change history rather than inside it. A revision knows the
|
||||
* title used to be something else; it cannot know that the reason this went
|
||||
* back is that the photo is missing, because nobody typed that anywhere the
|
||||
* history could see.
|
||||
*/
|
||||
#[Fillable(['user_id', 'user_name', 'event', 'body'])]
|
||||
class ReviewComment extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['event' => ReviewEvent::class];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function reviewable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live name when the account is at hand — people rename themselves —
|
||||
* and the recorded one otherwise, including once the account is gone.
|
||||
*/
|
||||
public function author(): string
|
||||
{
|
||||
if ($this->relationLoaded('user') && $this->user !== null) {
|
||||
return $this->user->name;
|
||||
}
|
||||
|
||||
return $this->user_name ?? __('panel.revisions.system');
|
||||
}
|
||||
}
|
||||
91
app/Models/Revision.php
Normal file
91
app/Models/Revision.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* One recorded change to one record.
|
||||
*
|
||||
* Keeps the author's name as well as the link to their account: people leave,
|
||||
* accounts get deleted, and "somebody published this in March" is worth less
|
||||
* than a name.
|
||||
*/
|
||||
#[Fillable(['user_id', 'user_name', 'event', 'changeset'])]
|
||||
class Revision extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['changeset' => 'array'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function revisionable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
// The live name when the account is at hand — people rename
|
||||
// themselves — and the recorded one otherwise, including once the
|
||||
// account is gone. Never a lazy load: this is read inside table rows.
|
||||
if ($this->relationLoaded('user') && $this->user !== null) {
|
||||
return $this->user->name;
|
||||
}
|
||||
|
||||
return $this->user_name ?? __('panel.revisions.system');
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes this revision touched.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function fields(): array
|
||||
{
|
||||
return array_keys($this->changeset ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a given attribute looked like before and after.
|
||||
*
|
||||
* @return array{old: mixed, new: mixed}
|
||||
*/
|
||||
public function diff(string $field): array
|
||||
{
|
||||
return [
|
||||
'old' => $this->changeset[$field]['old'] ?? null,
|
||||
'new' => $this->changeset[$field]['new'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The values needed to put a record back the way it was.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function previousValues(): array
|
||||
{
|
||||
return collect($this->changeset ?? [])
|
||||
->map(fn (array $change) => $change['old'] ?? null)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
129
app/Models/Service.php
Normal file
129
app/Models/Service.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use App\Models\Concerns\HasContentViews;
|
||||
use App\Models\Concerns\HasPublicationWindow;
|
||||
use App\Models\Concerns\HasReviews;
|
||||
use App\Models\Concerns\HasRevisions;
|
||||
use App\Models\Concerns\RecordsSlugChanges;
|
||||
use Database\Factories\ServiceFactory;
|
||||
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\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* Something the collective offers to do.
|
||||
*
|
||||
* The counterpart to a project: one says what has been built, this says what
|
||||
* you would be asking for. A portfolio without it is a list of work with no
|
||||
* explanation of what somebody is supposed to do about it.
|
||||
*
|
||||
* The relation to projects is what keeps it honest — a service that cannot
|
||||
* point at a real piece of work is a promise.
|
||||
*/
|
||||
#[Fillable([
|
||||
'title', 'slug', 'tagline', 'description', 'meta_title', 'meta_description',
|
||||
'icon', 'is_published', 'published_at', 'unpublish_at', 'is_featured', 'position',
|
||||
])]
|
||||
class Service extends Model
|
||||
{
|
||||
/** @use HasFactory<ServiceFactory> */
|
||||
use BumpsContentVersion, HasContentViews, HasFactory, HasPublicationWindow, HasReviews, HasRevisions, HasTranslations, RecordsSlugChanges, SoftDeletes;
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $translatable = [
|
||||
'title', 'slug', 'tagline', 'description', 'meta_title', 'meta_description',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_published' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'unpublish_at' => 'datetime',
|
||||
'is_featured' => 'boolean',
|
||||
'position' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the site is showing this right now.
|
||||
*/
|
||||
public function isPublishedNow(): bool
|
||||
{
|
||||
return $this->is_published
|
||||
&& ! $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 'service';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Project, $this>
|
||||
*/
|
||||
public function projects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Technology, $this>
|
||||
*/
|
||||
public function technologies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Technology::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Testimonial, $this>
|
||||
*/
|
||||
public function testimonials(): HasMany
|
||||
{
|
||||
return $this->hasMany(Testimonial::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('is_published', true)
|
||||
// The same window as pages and projects, enforced in one place so
|
||||
// nothing that reads published services has to remember it.
|
||||
->where(fn (Builder $q) => $q
|
||||
->whereNull('published_at')
|
||||
->orWhere('published_at', '<=', now()))
|
||||
->where(fn (Builder $q) => $q
|
||||
->whereNull('unpublish_at')
|
||||
->orWhere('unpublish_at', '>', now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
68
app/Models/Setting.php
Normal file
68
app/Models/Setting.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* A single runtime setting.
|
||||
*
|
||||
* Reads go through the cache, because some settings — maintenance mode above
|
||||
* all — are consulted on every public request. Writes clear that entry, so a
|
||||
* change in the panel takes effect immediately rather than after a TTL.
|
||||
*/
|
||||
#[Fillable(['key', 'value'])]
|
||||
class Setting extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['value' => 'array'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saved(fn (Setting $setting) => Cache::forget(self::cacheKey($setting->key)));
|
||||
static::deleted(fn (Setting $setting) => Cache::forget(self::cacheKey($setting->key)));
|
||||
}
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$value = Cache::rememberForever(
|
||||
self::cacheKey($key),
|
||||
// Wrapped in an array so a legitimately null value is cached as
|
||||
// "known to be null" rather than re-queried on every request.
|
||||
fn () => ['v' => self::query()->find($key)?->value],
|
||||
);
|
||||
|
||||
return $value['v'] ?? $default;
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): void
|
||||
{
|
||||
self::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
|
||||
Cache::forget(self::cacheKey($key));
|
||||
}
|
||||
|
||||
public static function forget(string $key): void
|
||||
{
|
||||
self::query()->whereKey($key)->delete();
|
||||
|
||||
Cache::forget(self::cacheKey($key));
|
||||
}
|
||||
|
||||
protected static function cacheKey(string $key): string
|
||||
{
|
||||
return "settings:{$key}";
|
||||
}
|
||||
}
|
||||
84
app/Models/Talk.php
Normal file
84
app/Models/Talk.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use App\Models\Concerns\HasRevisions;
|
||||
use Database\Factories\TalkFactory;
|
||||
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 Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* A talk given somewhere that is not this website.
|
||||
*
|
||||
* The cheapest content there is to keep a site looking alive, and the only
|
||||
* kind that shows the collective exists outside its own pages. Written once
|
||||
* here, it appears both on the landing page and on the profile of whoever gave
|
||||
* it.
|
||||
*/
|
||||
#[Fillable([
|
||||
'title', 'language', 'summary', 'event', 'city', 'held_at',
|
||||
'video_url', 'slides_url', 'member_id', 'project_id', 'is_public',
|
||||
])]
|
||||
class Talk extends Model
|
||||
{
|
||||
/** @use HasFactory<TalkFactory> */
|
||||
use BumpsContentVersion, HasFactory, HasRevisions, HasTranslations;
|
||||
|
||||
/**
|
||||
* The title is the name of that talk, given once in one language.
|
||||
* Translating it would invent a talk that never happened — only the
|
||||
* summary written about it afterwards is translated.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $translatable = ['summary'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'held_at' => 'date',
|
||||
'is_public' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Member, $this>
|
||||
*/
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeRecentFirst(Builder $query): Builder
|
||||
{
|
||||
return $query->orderByRaw('held_at is null')->orderByDesc('held_at');
|
||||
}
|
||||
}
|
||||
110
app/Models/Technology.php
Normal file
110
app/Models/Technology.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\RadarRing;
|
||||
use App\Enums\TechnologyCategory;
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use App\Models\Concerns\HasImages;
|
||||
use App\Models\Concerns\HasRevisions;
|
||||
use Database\Factories\TechnologyFactory;
|
||||
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 Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* A language, framework or tool the collective uses.
|
||||
*
|
||||
* Shared by projects, members and calls, which is what makes "who knows Rust"
|
||||
* and "projects built with Laravel" answerable without duplicating strings.
|
||||
*
|
||||
* The `name` is deliberately not translatable — technology names are proper
|
||||
* nouns. Only the prose about them is.
|
||||
*/
|
||||
#[Fillable([
|
||||
'name', 'slug', 'description', 'radar_note', 'category', 'radar_ring',
|
||||
'color', 'icon', 'logo', 'website', 'position',
|
||||
])]
|
||||
class Technology extends Model
|
||||
{
|
||||
/** @use HasFactory<TechnologyFactory> */
|
||||
use BumpsContentVersion, HasFactory, HasImages, HasRevisions, HasTranslations;
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $translatable = ['description', 'radar_note'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'category' => TechnologyCategory::class,
|
||||
'radar_ring' => RadarRing::class,
|
||||
'position' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function imageAttributes(): array
|
||||
{
|
||||
return ['logo'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Media, $this>
|
||||
*/
|
||||
public function logoMedia(): BelongsTo
|
||||
{
|
||||
return $this->imageRelation('logo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Project, $this>
|
||||
*/
|
||||
public function projects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Service, $this>
|
||||
*/
|
||||
public function services(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Service::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Member, $this>
|
||||
*/
|
||||
public function members(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Call, $this>
|
||||
*/
|
||||
public function calls(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Call::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Technologies placed on the radar. A technology with no ring is still
|
||||
* usable as a tag; it just does not appear in the radar view.
|
||||
*
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeOnRadar(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('radar_ring');
|
||||
}
|
||||
}
|
||||
106
app/Models/Testimonial.php
Normal file
106
app/Models/Testimonial.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BumpsContentVersion;
|
||||
use App\Models\Concerns\HasImages;
|
||||
use App\Models\Concerns\HasRevisions;
|
||||
use Database\Factories\TestimonialFactory;
|
||||
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;
|
||||
|
||||
/**
|
||||
* What somebody else says about the work.
|
||||
*
|
||||
* Attached to a client, a project or a service rather than to "the home page":
|
||||
* the frontend asks for the ones belonging to whatever it is rendering, which
|
||||
* is what stops this becoming a page-builder block.
|
||||
*/
|
||||
#[Fillable([
|
||||
'quote', 'original_locale', 'author_name', 'author_role', 'avatar',
|
||||
'client_id', 'project_id', 'service_id', 'is_public', 'position', 'given_at',
|
||||
])]
|
||||
class Testimonial extends Model
|
||||
{
|
||||
/** @use HasFactory<TestimonialFactory> */
|
||||
use BumpsContentVersion, HasFactory, HasImages, HasRevisions, HasTranslations, SoftDeletes;
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $translatable = ['quote', 'author_role'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_public' => 'boolean',
|
||||
'position' => 'integer',
|
||||
'given_at' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function imageAttributes(): array
|
||||
{
|
||||
return ['avatar'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Media, $this>
|
||||
*/
|
||||
public function avatarMedia(): BelongsTo
|
||||
{
|
||||
return $this->imageRelation('avatar');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Client, $this>
|
||||
*/
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Service, $this>
|
||||
*/
|
||||
public function service(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the text being shown is the one that was actually said.
|
||||
*
|
||||
* A translated quote is words put in somebody's mouth, so the frontend is
|
||||
* told, and can say "translated from Spanish" instead of pretending.
|
||||
*/
|
||||
public function isOriginal(?string $locale = null): bool
|
||||
{
|
||||
return ($locale ?? app()->getLocale()) === $this->original_locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
}
|
||||
}
|
||||
123
app/Models/User.php
Normal file
123
app/Models/User.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Models\Contracts\HasAvatar;
|
||||
use Filament\Models\Contracts\HasName;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
/**
|
||||
* A person who signed in through the Pireon identity server.
|
||||
*
|
||||
* Identity (name, email, active flag) is owned upstream and refreshed on every
|
||||
* login. Authorisation is owned here: the roles and permissions attached by
|
||||
* spatie/laravel-permission are the only thing that grants access.
|
||||
*/
|
||||
#[Fillable(['name', 'email', 'avatar_url', 'locale', 'is_active', 'oauth_id', 'remote_roles', 'last_login_at'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable implements FilamentUser, HasAvatar, HasName
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasRoles, Notifiable;
|
||||
|
||||
/**
|
||||
* Pin spatie's permission lookups to a single guard.
|
||||
*
|
||||
* The same person reaches this app through two guards — `web` in the panel,
|
||||
* `api` with a bearer token — but there is only one permission catalogue.
|
||||
* Without this, an API request would look for permissions registered under
|
||||
* the `api` guard, find none, and deny everything.
|
||||
*/
|
||||
protected string $guard_name = 'web';
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'last_login_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
'remote_roles' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Page, $this>
|
||||
*/
|
||||
public function pages(): HasMany
|
||||
{
|
||||
return $this->hasMany(Page::class, 'author_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every browser this account is currently signed in from, as Laravel's
|
||||
* own database session driver already knows it.
|
||||
*
|
||||
* @return HasMany<UserSession, $this>
|
||||
*/
|
||||
public function sessions(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserSession::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for the Filament panel.
|
||||
*
|
||||
* Two independent conditions must hold: the identity server still considers
|
||||
* the account usable, and this app has granted the account a role carrying
|
||||
* the `panel.access` permission.
|
||||
*/
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return $this->is_active && $this->can('panel.access');
|
||||
}
|
||||
|
||||
public function getFilamentName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getFilamentAvatarUrl(): ?string
|
||||
{
|
||||
return $this->avatar_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferred UI locale, falling back to the application default when the
|
||||
* user has never picked one or picked one we no longer support.
|
||||
*/
|
||||
public function preferredLocale(): string
|
||||
{
|
||||
$supported = array_keys(config('locales.supported'));
|
||||
|
||||
return in_array($this->locale, $supported, true)
|
||||
? $this->locale
|
||||
: (string) config('locales.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
75
app/Models/UserSession.php
Normal file
75
app/Models/UserSession.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* One browser's login, as Laravel's own database session driver already
|
||||
* records it.
|
||||
*
|
||||
* Not a table this application created — `sessions` is the one Laravel's
|
||||
* `DatabaseSessionHandler` writes to on every request when
|
||||
* `SESSION_DRIVER=database`, which production always runs. This model exists
|
||||
* only to make that table readable in the panel; nothing here changes how a
|
||||
* session is written or expired.
|
||||
*
|
||||
* Deactivating somebody in this panel (`is_active = false`) already stops the
|
||||
* *next* request the moment the guard re-checks it. What it does not do is
|
||||
* touch a session already in progress, and there was never anywhere to see
|
||||
* one, let alone end it before it next made a request. This closes that gap.
|
||||
*/
|
||||
#[Fillable(['id', 'user_id', 'ip_address', 'user_agent', 'payload', 'last_activity'])]
|
||||
class UserSession extends Model
|
||||
{
|
||||
protected $table = 'sessions';
|
||||
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
// Written once by the session handler and never touched again; there is
|
||||
// no `created_at`/`updated_at` pair on this table to cast.
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'last_activity' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this row is the request reading it right now — the one session
|
||||
* this screen must never offer to end, because doing so would sign out
|
||||
* the person clicking the button mid-click.
|
||||
*/
|
||||
public function isCurrent(): bool
|
||||
{
|
||||
return $this->id === session()->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopeRecent(Builder $query): Builder
|
||||
{
|
||||
return $query->orderByDesc('last_activity');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user