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:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

131
app/Models/Application.php Normal file
View 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
View 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
View 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
View 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);
}
}

View 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);
}
}

View 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',
);
}
}

View 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'));
}
}

View 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());
}
}

View 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()),
);
}
}

View 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,
};
}
}

View 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();
}
}

View 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());
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}

View 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');
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}

View 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');
}
}