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 */ 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 */ public function reviewRequester(): BelongsTo { return $this->belongsTo(User::class, 'review_requested_by'); } /** * @return BelongsTo */ 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 $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 $query * @return Builder */ 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 $query * @return Builder */ public function scopeAwaitingReviewBy(Builder $query, User $user): Builder { return $query->awaitingReview()->where( fn (Builder $scope) => $scope ->whereNull('reviewer_id') ->orWhere('reviewer_id', $user->getAuthIdentifier()), ); } }