Review, publish, preview and undo changes through the API
Revisions now call publishing only what publishes: a project's status is whether the work is active, and a service is published by its flag. The fake identity server answers by token, so a test can sign in a second person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
29
app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php
Normal file
29
app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Concerns;
|
||||
|
||||
use App\Support\AdminContent;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* For the endpoints shared by several kinds of content —
|
||||
* `/pages/{page}/revisions`, `/projects/{project}/revisions` — whose route
|
||||
* says which kind through its `content` default.
|
||||
*/
|
||||
trait ResolvesContent
|
||||
{
|
||||
protected function content(Request $request): string
|
||||
{
|
||||
return (string) $request->route('content');
|
||||
}
|
||||
|
||||
protected function record(Request $request): Model
|
||||
{
|
||||
$content = $this->content($request);
|
||||
|
||||
return AdminContent::find($content, $request->route(AdminContent::parameter($content)));
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/Api/V1/PreviewController.php
Normal file
43
app/Http/Controllers/Api/V1/PreviewController.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminContent;
|
||||
use App\Support\PreviewLink;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Links to see a draft on the real site, one per language it has an address
|
||||
* in — the panel's preview button. Each opens that one record only, and stops
|
||||
* working after `PreviewLink::HOURS`.
|
||||
*/
|
||||
class PreviewController extends Controller
|
||||
{
|
||||
use ResolvesContent;
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$record = $this->record($request);
|
||||
|
||||
$this->authorize('view', $record);
|
||||
|
||||
$type = AdminContent::PREVIEWABLE[$this->content($request)];
|
||||
|
||||
$links = collect(array_keys((array) config('locales.supported')))
|
||||
->mapWithKeys(fn (string $locale) => [$locale => PreviewLink::for($record, $type, $locale)])
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'links' => (object) $links,
|
||||
'expires_at' => now()->addHours(PreviewLink::HOURS)->toIso8601String(),
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
187
app/Http/Controllers/Api/V1/ReviewController.php
Normal file
187
app/Http/Controllers/Api/V1/ReviewController.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ReviewCommentResource;
|
||||
use App\Models\User;
|
||||
use App\Support\AdminContent;
|
||||
use App\Support\ContentTypes;
|
||||
use App\Support\Publisher;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* The handover between writing something and putting it on the site — the
|
||||
* panel's review buttons, as calls. Pages, projects and services only: the
|
||||
* kinds with a separate `publish` permission.
|
||||
*/
|
||||
class ReviewController extends Controller
|
||||
{
|
||||
use ResolvesContent;
|
||||
|
||||
/**
|
||||
* What is waiting for the caller: everything awaiting review they could
|
||||
* answer, either addressed to them or to nobody in particular.
|
||||
*/
|
||||
public function queue(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$items = collect();
|
||||
|
||||
foreach (array_keys(AdminContent::REVIEWABLE) as $resource) {
|
||||
$model = AdminContent::model($resource);
|
||||
|
||||
if ($user->cannot("{$resource}.publish")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$model::query()
|
||||
->awaitingReviewBy($user)
|
||||
->with(['reviewRequester', 'reviewer'])
|
||||
->get()
|
||||
->each(fn (Model $record) => $items->push($this->summary($resource, $record)));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $items->sortBy('requested_at')->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread: requests, returns, publications and comments, newest first.
|
||||
*/
|
||||
public function thread(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$record = $this->record($request);
|
||||
|
||||
$this->authorize('view', $record);
|
||||
|
||||
return ReviewCommentResource::collection(
|
||||
$record->reviewComments()->with('user')->paginate(perPage: min($request->integer('per_page', 25), 100)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for it to be reviewed — the only way somebody without the publish
|
||||
* permission has of putting their work live.
|
||||
*/
|
||||
public function request(Request $request): JsonResponse
|
||||
{
|
||||
$record = $this->record($request);
|
||||
|
||||
$this->authorize('update', $record);
|
||||
|
||||
$data = $request->validate([
|
||||
'reviewer_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'],
|
||||
'note' => ['sometimes', 'nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
if ($record->isAwaitingReview() || $record->isPublishedNow()) {
|
||||
throw ValidationException::withMessages(['review' => [__('rules.reviews.not_requestable')]]);
|
||||
}
|
||||
|
||||
$reviewer = isset($data['reviewer_id']) ? User::find($data['reviewer_id']) : null;
|
||||
|
||||
if ($reviewer !== null && $reviewer->cannot($record->getTable().'.publish')) {
|
||||
throw ValidationException::withMessages(['reviewer_id' => [__('rules.reviews.not_a_reviewer')]]);
|
||||
}
|
||||
|
||||
$record->requestReview($reviewer, $data['note'] ?? null, $request->user());
|
||||
|
||||
return $this->answer($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send it back to whoever asked, saying why.
|
||||
*/
|
||||
public function return(Request $request): JsonResponse
|
||||
{
|
||||
$record = $this->record($request);
|
||||
|
||||
$this->authorize($record->getTable().'.publish');
|
||||
|
||||
$data = $request->validate(['note' => ['required', 'string', 'max:2000']]);
|
||||
|
||||
if (! $record->isAwaitingReview()) {
|
||||
throw ValidationException::withMessages(['review' => [__('rules.reviews.not_awaiting')]]);
|
||||
}
|
||||
|
||||
$record->returnForChanges($data['note'], $request->user());
|
||||
|
||||
return $this->answer($record);
|
||||
}
|
||||
|
||||
public function comment(Request $request): JsonResponse
|
||||
{
|
||||
$record = $this->record($request);
|
||||
|
||||
$this->authorize('view', $record);
|
||||
|
||||
$data = $request->validate(['body' => ['required', 'string', 'max:2000']]);
|
||||
|
||||
return ReviewCommentResource::make($record->comment($data['body'], $request->user())->load('user'))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve: on the site now, or from `at`. Either way the review, if there
|
||||
* was one, is answered.
|
||||
*/
|
||||
public function publish(Request $request): JsonResponse
|
||||
{
|
||||
$record = $this->record($request);
|
||||
|
||||
$this->authorize($record->getTable().'.publish');
|
||||
|
||||
$data = $request->validate(['at' => ['sometimes', 'nullable', 'date']]);
|
||||
|
||||
Publisher::publish($record, filled($data['at'] ?? null) ? CarbonImmutable::parse($data['at']) : null);
|
||||
|
||||
return $this->answer($record);
|
||||
}
|
||||
|
||||
public function unpublish(Request $request): JsonResponse
|
||||
{
|
||||
$record = $this->record($request);
|
||||
|
||||
$this->authorize($record->getTable().'.publish');
|
||||
|
||||
Publisher::unpublish($record);
|
||||
|
||||
return $this->answer($record);
|
||||
}
|
||||
|
||||
protected function answer(Model $record): JsonResponse
|
||||
{
|
||||
$record->refresh()->load(['reviewRequester', 'reviewer']);
|
||||
|
||||
return response()->json(['data' => $this->summary(AdminContent::nameOf($record), $record)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function summary(string $resource, Model $record): array
|
||||
{
|
||||
return [
|
||||
'type' => $resource,
|
||||
'id' => $record->getKey(),
|
||||
'title' => ContentTypes::title($record),
|
||||
'published' => $record->isPublishedNow(),
|
||||
'published_at' => $record->published_at?->toIso8601String(),
|
||||
'awaiting_review' => $record->isAwaitingReview(),
|
||||
'requested_at' => $record->review_requested_at?->toIso8601String(),
|
||||
'requested_by' => $record->reviewRequester ? ['id' => $record->reviewRequester->id, 'name' => $record->reviewRequester->name] : null,
|
||||
'reviewer' => $record->reviewer ? ['id' => $record->reviewer->id, 'name' => $record->reviewer->name] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/Api/V1/RevisionController.php
Normal file
74
app/Http/Controllers/Api/V1/RevisionController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Api\V1\Concerns\ResolvesContent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\RevisionResource;
|
||||
use App\Models\Revision;
|
||||
use App\Support\AdminContent;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* A record's history: who changed what and when, and undoing one change.
|
||||
*/
|
||||
class RevisionController extends Controller
|
||||
{
|
||||
use ResolvesContent;
|
||||
|
||||
/**
|
||||
* Newest first. Filter: event.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$record = $this->record($request);
|
||||
|
||||
$this->authorize('view', $record);
|
||||
|
||||
$revisions = $record->revisions()
|
||||
->with('user')
|
||||
->when($request->filled('event'), fn ($q) => $q->where('event', $request->string('event')))
|
||||
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||
->withQueryString();
|
||||
|
||||
return RevisionResource::collection($revisions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put back what one revision changed, as the panel's history does.
|
||||
* Restoring is itself a change, so it lands in the history too — the way
|
||||
* back is never lost.
|
||||
*/
|
||||
public function restore(Request $request, Revision $revision): RevisionResource
|
||||
{
|
||||
$model = Relation::getMorphedModel($revision->revisionable_type) ?? $revision->revisionable_type;
|
||||
$record = AdminContent::findOf($model, $revision->revisionable_id);
|
||||
|
||||
$this->authorize('update', $record);
|
||||
|
||||
$values = $revision->previousValues();
|
||||
|
||||
if ($values === []) {
|
||||
throw ValidationException::withMessages(['revision' => [__('rules.revisions.nothing_to_restore')]]);
|
||||
}
|
||||
|
||||
// Undoing a change can publish or unpublish, and that stays with whoever
|
||||
// may publish.
|
||||
$guarded = array_intersect(array_keys($values), AdminContent::publicationFields($record));
|
||||
|
||||
if ($guarded !== [] && $request->user()->cannot($record->getTable().'.publish')) {
|
||||
throw ValidationException::withMessages(['revision' => [__('rules.publication.forbidden')]]);
|
||||
}
|
||||
|
||||
// Only what the model lets a form write: a revision may mention
|
||||
// bookkeeping columns, and those are not anybody's to put back.
|
||||
$record->forceFill(array_intersect_key($values, array_flip($record->getFillable())))->save();
|
||||
|
||||
return RevisionResource::make($record->revisions()->with('user')->first());
|
||||
}
|
||||
}
|
||||
32
app/Http/Resources/ReviewCommentResource.php
Normal file
32
app/Http/Resources/ReviewCommentResource.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\ReviewComment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin ReviewComment
|
||||
*/
|
||||
class ReviewCommentResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'event' => $this->event->value,
|
||||
'body' => $this->body,
|
||||
'author' => [
|
||||
'id' => $this->user_id,
|
||||
'name' => $this->user?->name ?? $this->user_name,
|
||||
],
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Http/Resources/RevisionResource.php
Normal file
33
app/Http/Resources/RevisionResource.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Revision;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Revision
|
||||
*/
|
||||
class RevisionResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'event' => $this->event,
|
||||
'author' => [
|
||||
'id' => $this->user_id,
|
||||
'name' => $this->resource->author(),
|
||||
],
|
||||
// Field => {old, new}. Translatable fields carry every language.
|
||||
'changes' => (object) ($this->changeset ?? []),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\Revision;
|
||||
use App\Support\AdminContent;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -119,7 +120,12 @@ trait HasRevisions
|
||||
*/
|
||||
protected function revisionEvent(array $changes): string
|
||||
{
|
||||
if (! array_key_exists('published_at', $changes) && ! array_key_exists('status', $changes)) {
|
||||
// Which fields put this kind of record on the site: a project's
|
||||
// `status` is whether the work is active, not whether it is public,
|
||||
// and a service is published by a flag.
|
||||
$publication = AdminContent::publicationFields($this) ?: ['published_at', 'status'];
|
||||
|
||||
if (array_intersect(array_keys($changes), $publication) === []) {
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
|
||||
132
app/Support/AdminContent.php
Normal file
132
app/Support/AdminContent.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Category;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\Talk;
|
||||
use App\Models\Technology;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* The content the admin API addresses by name — `pages`, `projects` — and
|
||||
* what each one supports beyond plain editing: a history, a review, a preview.
|
||||
*
|
||||
* One table rather than a flag on every model, so the routes, the
|
||||
* specification and the controllers that serve those extras all read the same
|
||||
* answer.
|
||||
*/
|
||||
final class AdminContent
|
||||
{
|
||||
/**
|
||||
* API name => [model, route parameter].
|
||||
*
|
||||
* @var array<string, array{0: class-string<Model>, 1: string}>
|
||||
*/
|
||||
public const WITH_HISTORY = [
|
||||
'pages' => [Page::class, 'page'],
|
||||
'categories' => [Category::class, 'category'],
|
||||
'projects' => [Project::class, 'project'],
|
||||
'members' => [Member::class, 'member'],
|
||||
'services' => [Service::class, 'service'],
|
||||
'clients' => [Client::class, 'client'],
|
||||
'testimonials' => [Testimonial::class, 'testimonial'],
|
||||
'talks' => [Talk::class, 'talk'],
|
||||
'technologies' => [Technology::class, 'technology'],
|
||||
'calls' => [Call::class, 'call'],
|
||||
];
|
||||
|
||||
/**
|
||||
* What goes through a review before it reaches the site, and the fields
|
||||
* that put each one there — the ones `{resource}.publish` guards.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
public const REVIEWABLE = [
|
||||
'pages' => ['status', 'published_at', 'unpublish_at'],
|
||||
'projects' => ['published_at', 'unpublish_at'],
|
||||
'services' => ['is_published', 'published_at', 'unpublish_at'],
|
||||
];
|
||||
|
||||
/**
|
||||
* What has an address on the public site to preview, by the type name
|
||||
* `SiteUrls` knows it as.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const PREVIEWABLE = [
|
||||
'pages' => 'page',
|
||||
'projects' => 'project',
|
||||
'services' => 'service',
|
||||
'calls' => 'call',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return class-string<Model>
|
||||
*/
|
||||
public static function model(string $resource): string
|
||||
{
|
||||
return self::WITH_HISTORY[$resource][0] ?? abort(404);
|
||||
}
|
||||
|
||||
public static function parameter(string $resource): string
|
||||
{
|
||||
return self::WITH_HISTORY[$resource][1] ?? abort(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* The record a route names, binned ones included: their history is still
|
||||
* worth reading, and restoring them is what the bin is for.
|
||||
*/
|
||||
public static function find(string $resource, mixed $id): Model
|
||||
{
|
||||
return self::findOf(self::model($resource), $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<Model> $model
|
||||
*/
|
||||
public static function findOf(string $model, mixed $id): Model
|
||||
{
|
||||
$query = $model::query();
|
||||
|
||||
if (in_array(SoftDeletes::class, class_uses_recursive($model), true)) {
|
||||
$query->withTrashed();
|
||||
}
|
||||
|
||||
return $query->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The API name of a record, for a review queue that mixes several kinds.
|
||||
*/
|
||||
public static function nameOf(Model $record): ?string
|
||||
{
|
||||
foreach (self::WITH_HISTORY as $resource => [$model]) {
|
||||
if ($record instanceof $model) {
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields only `{resource}.publish` may write, for this record.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function publicationFields(Model $record): array
|
||||
{
|
||||
return self::REVIEWABLE[self::nameOf($record)] ?? [];
|
||||
}
|
||||
}
|
||||
53
app/Support/Publisher.php
Normal file
53
app/Support/Publisher.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Putting a record on the site, or taking it off, whichever fields that means
|
||||
* for its kind: a page has a status, a project only a date, a service a flag
|
||||
* and a date.
|
||||
*/
|
||||
final class Publisher
|
||||
{
|
||||
/**
|
||||
* Live now, or from `$at` when it is in the future. Whatever review it was
|
||||
* waiting for is answered either way: scheduling is the decision.
|
||||
*/
|
||||
public static function publish(Model $record, ?CarbonInterface $at = null): void
|
||||
{
|
||||
$at ??= now();
|
||||
|
||||
$record->forceFill(match (true) {
|
||||
$record instanceof Page => ['status' => PageStatus::Published, 'published_at' => $at],
|
||||
$record instanceof Project => ['published_at' => $at],
|
||||
$record instanceof Service => ['is_published' => true, 'published_at' => $at],
|
||||
default => throw new InvalidArgumentException($record::class.' is not published this way.'),
|
||||
})->save();
|
||||
|
||||
// Publishing now closes the review by itself (HasReviews); a date in
|
||||
// the future does not, so it is closed here.
|
||||
if ($record->isAwaitingReview()) {
|
||||
$record->closeReview();
|
||||
}
|
||||
}
|
||||
|
||||
public static function unpublish(Model $record): void
|
||||
{
|
||||
$record->forceFill(match (true) {
|
||||
$record instanceof Page => ['status' => PageStatus::Draft],
|
||||
$record instanceof Project => ['published_at' => null],
|
||||
$record instanceof Service => ['is_published' => false],
|
||||
default => throw new InvalidArgumentException($record::class.' is not published this way.'),
|
||||
})->save();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user