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:
2026-10-01 08:29:48 +02:00
parent 4c4a2b3299
commit 5b46436641
18 changed files with 1899 additions and 13 deletions

View File

@@ -1302,6 +1302,36 @@ salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las
que había, y las no enviadas no se tocan; todo se guarda en una transacción. que había, y las no enviadas no se tocan; todo se guarda en una transacción.
### Historial, revisión y vista previa
```
GET /api/v1/{tipo}/{id}/revisions historial (páginas, categorías, proyectos,
miembros, servicios, clientes, testimonios,
charlas, tecnologías, convocatorias)
POST /api/v1/revisions/{id}/restore deshacer lo que cambió una revisión
GET /api/v1/reviews lo que espera mi revisión
GET /api/v1/{tipo}/{id}/reviews el hilo (pages, projects, services)
POST /api/v1/{tipo}/{id}/comments comentar
POST /api/v1/{tipo}/{id}/review pedir revisión {reviewer_id?, note?}
POST /api/v1/{tipo}/{id}/return devolver con motivo {note}
POST /api/v1/{tipo}/{id}/publish aprobar: ya, o programado con {at}
POST /api/v1/{tipo}/{id}/unpublish
POST /api/v1/{tipo}/{id}/preview enlaces de vista previa por idioma
```
Lo mismo que los botones del panel, con las mismas reglas: pedir revisión es
para quien escribe sin `{tipo}.publish`, devolver y publicar son para quien lo
tiene, y deshacer una revisión que tocó la publicación también lo pide.
Programar una publicación responde la revisión en ese momento, no cuando llega
la fecha: la decisión ya está tomada. Los enlaces de vista previa son los del
panel —un registro, un idioma, unas horas— y [`AdminContent`](app/Support/AdminContent.php)
es la tabla de qué tipo admite cada cosa, la que leen las rutas, los
controladores y el historial.
El historial ahora llama «publicado» solo a lo que publica de verdad: cambiar
el `status` de un proyecto (activo, en pausa) salía como publicación, y activar
un servicio no.
Los ajustes siguen los permisos del panel: `settings.view` para leer, Los ajustes siguen los permisos del panel: `settings.view` para leer,
`settings.update` para cambiar y `settings.maintenance` para `site.maintenance` `settings.update` para cambiar y `settings.maintenance` para `site.maintenance`
y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo

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

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

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

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

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

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models\Concerns; namespace App\Models\Concerns;
use App\Models\Revision; use App\Models\Revision;
use App\Support\AdminContent;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -119,7 +120,12 @@ trait HasRevisions
*/ */
protected function revisionEvent(array $changes): string 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'; return 'updated';
} }

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

View File

@@ -29,6 +29,16 @@ return [
'cannot_publish' => 'No tens permís per publicar pàgines.', 'cannot_publish' => 'No tens permís per publicar pàgines.',
], ],
'revisions' => [
'nothing_to_restore' => 'Aquesta revisió no té canvis per desfer.',
],
'reviews' => [
'not_requestable' => 'Ja està esperant revisió o ja està publicat.',
'not_a_reviewer' => 'Aquesta persona no pot publicar això, així que no ho pot revisar.',
'not_awaiting' => 'No està esperant revisió.',
],
'settings' => [ 'settings' => [
'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.', 'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.',
], ],

View File

@@ -29,6 +29,16 @@ return [
'cannot_publish' => 'You do not have permission to publish pages.', 'cannot_publish' => 'You do not have permission to publish pages.',
], ],
'revisions' => [
'nothing_to_restore' => 'This revision has no changes to undo.',
],
'reviews' => [
'not_requestable' => 'It is already awaiting review or already published.',
'not_a_reviewer' => 'That person cannot publish this, so they cannot review it.',
'not_awaiting' => 'It is not awaiting review.',
],
'settings' => [ 'settings' => [
'read_only' => 'This setting is written by the application and cannot be edited by hand.', 'read_only' => 'This setting is written by the application and cannot be edited by hand.',
], ],

View File

@@ -29,6 +29,16 @@ return [
'cannot_publish' => 'No tienes permiso para publicar páginas.', 'cannot_publish' => 'No tienes permiso para publicar páginas.',
], ],
'revisions' => [
'nothing_to_restore' => 'Esta revisión no tiene cambios que deshacer.',
],
'reviews' => [
'not_requestable' => 'Ya está esperando revisión o ya está publicado.',
'not_a_reviewer' => 'Esa persona no puede publicar esto, así que no puede revisarlo.',
'not_awaiting' => 'No está esperando revisión.',
],
'settings' => [ 'settings' => [
'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.', 'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.',
], ],

View File

@@ -130,6 +130,10 @@ tags:
description: The collective's git repositories and their synced numbers description: The collective's git repositories and their synced numbers
- name: Settings - name: Settings
description: Runtime settings and the public site's maintenance mode description: Runtime settings and the public site's maintenance mode
- name: History
description: Who changed what and when, and undoing a change
- name: Reviews
description: Review, publication and preview links
- name: Users - name: Users
description: Local user administration description: Local user administration
- name: Media - name: Media
@@ -2650,6 +2654,879 @@ paths:
"403": { $ref: "#/components/responses/Forbidden" } "403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" } "422": { $ref: "#/components/responses/ValidationError" }
/v1/pages/{page}/revisions:
get:
tags: [History]
summary: List the history of a page
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: page
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/categories/{category}/revisions:
get:
tags: [History]
summary: List the history of a category
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: category
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects/{project}/revisions:
get:
tags: [History]
summary: List the history of a project
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: project
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/members/{member}/revisions:
get:
tags: [History]
summary: List the history of a member
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: member
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services/{service}/revisions:
get:
tags: [History]
summary: List the history of a service
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: service
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/clients/{client}/revisions:
get:
tags: [History]
summary: List the history of a client
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: client
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/testimonials/{testimonial}/revisions:
get:
tags: [History]
summary: List the history of a testimonial
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: testimonial
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/talks/{talk}/revisions:
get:
tags: [History]
summary: List the history of a talk
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: talk
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/technologies/{technology}/revisions:
get:
tags: [History]
summary: List the history of a technology
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: technology
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/calls/{call}/revisions:
get:
tags: [History]
summary: List the history of a call
description: Who changed what and when, newest first. Binned records included.
parameters:
- name: call
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
- name: event
in: query
description: "`created`, `updated`, `published`, `unpublished`, `deleted`, `restored` or `erased`."
schema: { type: string }
responses:
"200":
description: Paginated revisions
content:
application/json:
schema: { $ref: "#/components/schemas/RevisionCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/revisions/{revision}/restore:
post:
tags: [History]
summary: Undo a revision
description: |
Puts back the values one revision changed, as the panel's history
does. Restoring is itself a change and lands in the history. Requires
permission to edit the record, and to publish it when the revision
touched its publication.
parameters:
- name: revision
in: path
required: true
schema: { type: integer }
responses:
"200":
description: The revision the restore recorded
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Revision" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/reviews:
get:
tags: [Reviews]
summary: What is waiting for my review
description: |
Pages, projects and services awaiting review that the caller could
publish — addressed to them, or to nobody in particular. Oldest
request first, unpaginated.
responses:
"200":
description: The queue
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/ReviewState" }
/v1/pages/{page}/reviews:
get:
tags: [Reviews]
summary: Review thread of a page
description: Requests, returns, publications and comments, newest first.
parameters:
- name: page
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: Paginated thread
content:
application/json:
schema: { $ref: "#/components/schemas/ReviewCommentCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/pages/{page}/comments:
post:
tags: [Reviews]
summary: Comment on the review of a page
parameters:
- name: page
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body: { type: string, maxLength: 2000 }
responses:
"201":
description: Comment added
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewComment" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/pages/{page}/review:
post:
tags: [Reviews]
summary: Ask for a page to be reviewed
description: |
The way to put it live without `pages.publish`: whoever can publish it
is told. `reviewer_id` addresses one of them; without it, anyone may
answer. Refused if it is already waiting or already published.
parameters:
- name: page
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
reviewer_id: { type: integer, nullable: true }
note: { type: string, nullable: true, maxLength: 2000 }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/pages/{page}/return:
post:
tags: [Reviews]
summary: Send a page back for changes
description: Tells whoever asked, with the reason. Requires `pages.publish`.
parameters:
- name: page
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [note]
properties:
note: { type: string, maxLength: 2000 }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/pages/{page}/publish:
post:
tags: [Reviews]
summary: Publish a page, now or later
description: |
Approves it: live now, or from `at` when that is in the future. Any
pending review is answered either way. Requires `pages.publish`.
parameters:
- name: page
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
at: { type: string, format: date-time, nullable: true }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/pages/{page}/unpublish:
post:
tags: [Reviews]
summary: Take a page off the site
description: Requires `pages.publish`.
parameters:
- name: page
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects/{project}/reviews:
get:
tags: [Reviews]
summary: Review thread of a project
description: Requests, returns, publications and comments, newest first.
parameters:
- name: project
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: Paginated thread
content:
application/json:
schema: { $ref: "#/components/schemas/ReviewCommentCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects/{project}/comments:
post:
tags: [Reviews]
summary: Comment on the review of a project
parameters:
- name: project
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body: { type: string, maxLength: 2000 }
responses:
"201":
description: Comment added
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewComment" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/projects/{project}/review:
post:
tags: [Reviews]
summary: Ask for a project to be reviewed
description: |
The way to put it live without `projects.publish`: whoever can publish it
is told. `reviewer_id` addresses one of them; without it, anyone may
answer. Refused if it is already waiting or already published.
parameters:
- name: project
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
reviewer_id: { type: integer, nullable: true }
note: { type: string, nullable: true, maxLength: 2000 }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/projects/{project}/return:
post:
tags: [Reviews]
summary: Send a project back for changes
description: Tells whoever asked, with the reason. Requires `projects.publish`.
parameters:
- name: project
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [note]
properties:
note: { type: string, maxLength: 2000 }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/projects/{project}/publish:
post:
tags: [Reviews]
summary: Publish a project, now or later
description: |
Approves it: live now, or from `at` when that is in the future. Any
pending review is answered either way. Requires `projects.publish`.
parameters:
- name: project
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
at: { type: string, format: date-time, nullable: true }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/projects/{project}/unpublish:
post:
tags: [Reviews]
summary: Take a project off the site
description: Requires `projects.publish`.
parameters:
- name: project
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services/{service}/reviews:
get:
tags: [Reviews]
summary: Review thread of a service
description: Requests, returns, publications and comments, newest first.
parameters:
- name: service
in: path
required: true
schema: { type: integer }
- $ref: "#/components/parameters/PerPage"
responses:
"200":
description: Paginated thread
content:
application/json:
schema: { $ref: "#/components/schemas/ReviewCommentCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services/{service}/comments:
post:
tags: [Reviews]
summary: Comment on the review of a service
parameters:
- name: service
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body: { type: string, maxLength: 2000 }
responses:
"201":
description: Comment added
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewComment" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/services/{service}/review:
post:
tags: [Reviews]
summary: Ask for a service to be reviewed
description: |
The way to put it live without `services.publish`: whoever can publish it
is told. `reviewer_id` addresses one of them; without it, anyone may
answer. Refused if it is already waiting or already published.
parameters:
- name: service
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
reviewer_id: { type: integer, nullable: true }
note: { type: string, nullable: true, maxLength: 2000 }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/services/{service}/return:
post:
tags: [Reviews]
summary: Send a service back for changes
description: Tells whoever asked, with the reason. Requires `services.publish`.
parameters:
- name: service
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [note]
properties:
note: { type: string, maxLength: 2000 }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/services/{service}/publish:
post:
tags: [Reviews]
summary: Publish a service, now or later
description: |
Approves it: live now, or from `at` when that is in the future. Any
pending review is answered either way. Requires `services.publish`.
parameters:
- name: service
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
at: { type: string, format: date-time, nullable: true }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/services/{service}/unpublish:
post:
tags: [Reviews]
summary: Take a service off the site
description: Requires `services.publish`.
parameters:
- name: service
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Where the review stands
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/ReviewState" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/pages/{page}/preview:
post:
tags: [Reviews]
summary: Preview links for a page
description: |
One link per language it has an address in, to see it on the real
site before it is published. Each opens this record only and expires
a few hours later.
parameters:
- name: page
in: path
required: true
schema: { type: integer }
responses:
"201":
description: Preview links
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PreviewLinks" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects/{project}/preview:
post:
tags: [Reviews]
summary: Preview links for a project
description: |
One link per language it has an address in, to see it on the real
site before it is published. Each opens this record only and expires
a few hours later.
parameters:
- name: project
in: path
required: true
schema: { type: integer }
responses:
"201":
description: Preview links
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PreviewLinks" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services/{service}/preview:
post:
tags: [Reviews]
summary: Preview links for a service
description: |
One link per language it has an address in, to see it on the real
site before it is published. Each opens this record only and expires
a few hours later.
parameters:
- name: service
in: path
required: true
schema: { type: integer }
responses:
"201":
description: Preview links
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PreviewLinks" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/calls/{call}/preview:
post:
tags: [Reviews]
summary: Preview links for a call
description: |
One link per language it has an address in, to see it on the real
site before it is published. Each opens this record only and expires
a few hours later.
parameters:
- name: call
in: path
required: true
schema: { type: integer }
responses:
"201":
description: Preview links
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/PreviewLinks" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/media: /v1/media:
get: get:
tags: [Media] tags: [Media]
@@ -4323,6 +5200,83 @@ components:
until: { type: string, format: date-time, nullable: true } until: { type: string, format: date-time, nullable: true }
started_at: { type: string, format: date-time, nullable: true } started_at: { type: string, format: date-time, nullable: true }
Revision:
type: object
properties:
id: { type: integer }
event: { type: string, enum: [created, updated, published, unpublished, deleted, restored, erased] }
author:
type: object
properties:
id: { type: integer, nullable: true }
name: { type: string }
changes:
type: object
description: "Field → `{old, new}`. Translatable fields carry every language."
additionalProperties:
type: object
properties:
old: { nullable: true }
new: { nullable: true }
created_at: { type: string, format: date-time }
RevisionCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Revision" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
ReviewComment:
type: object
properties:
id: { type: integer }
event: { type: string, enum: [requested, returned, published, commented] }
body: { type: string, nullable: true }
author:
type: object
properties:
id: { type: integer, nullable: true }
name: { type: string, nullable: true }
created_at: { type: string, format: date-time }
ReviewCommentCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/ReviewComment" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
ReviewState:
type: object
properties:
type: { type: string, enum: [pages, projects, services] }
id: { type: integer }
title: { type: string, nullable: true }
published: { type: boolean, description: On the site right now. }
published_at: { type: string, format: date-time, nullable: true, description: In the future when scheduled. }
awaiting_review: { type: boolean }
requested_at: { type: string, format: date-time, nullable: true }
requested_by: { $ref: "#/components/schemas/UserRef" }
reviewer: { $ref: "#/components/schemas/UserRef" }
UserRef:
type: object
nullable: true
properties:
id: { type: integer }
name: { type: string }
PreviewLinks:
type: object
properties:
links: { type: object, additionalProperties: { type: string, format: uri }, description: Language → link. }
expires_at: { type: string, format: date-time }
MediaFor: MediaFor:
type: string type: string
enum: [pages, technologies, projects, members, clients, testimonials] enum: [pages, technologies, projects, members, clients, testimonials]

View File

@@ -20,9 +20,12 @@ use App\Http\Controllers\Api\V1\MediaController;
use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\MissingUrlController; use App\Http\Controllers\Api\V1\MissingUrlController;
use App\Http\Controllers\Api\V1\PageController; use App\Http\Controllers\Api\V1\PageController;
use App\Http\Controllers\Api\V1\PreviewController;
use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\RedirectController; use App\Http\Controllers\Api\V1\RedirectController;
use App\Http\Controllers\Api\V1\RepositoryController; use App\Http\Controllers\Api\V1\RepositoryController;
use App\Http\Controllers\Api\V1\ReviewController;
use App\Http\Controllers\Api\V1\RevisionController;
use App\Http\Controllers\Api\V1\RoleController; use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\ServiceController; use App\Http\Controllers\Api\V1\ServiceController;
use App\Http\Controllers\Api\V1\SettingController; use App\Http\Controllers\Api\V1\SettingController;
@@ -31,6 +34,7 @@ use App\Http\Controllers\Api\V1\TechnologyController;
use App\Http\Controllers\Api\V1\TestimonialController; use App\Http\Controllers\Api\V1\TestimonialController;
use App\Http\Controllers\Api\V1\TokenController; use App\Http\Controllers\Api\V1\TokenController;
use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\UserController;
use App\Support\AdminContent;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
/* /*
@@ -226,6 +230,44 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update'); Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update');
Route::apiResource('settings', SettingController::class)->only(['index', 'show', 'update']); Route::apiResource('settings', SettingController::class)->only(['index', 'show', 'update']);
// History, review and preview, for the kinds of content that have
// them. Each route says which kind through `content`, so one
// controller serves them all and the specification still names each.
foreach (AdminContent::WITH_HISTORY as $resource => [, $parameter]) {
Route::get("{$resource}/{{$parameter}}/revisions", [RevisionController::class, 'index'])
->defaults('content', $resource)
->name("{$resource}.revisions");
}
Route::post('revisions/{revision}/restore', [RevisionController::class, 'restore'])->name('revisions.restore');
Route::get('reviews', [ReviewController::class, 'queue'])->name('reviews.queue');
foreach (array_keys(AdminContent::REVIEWABLE) as $resource) {
$parameter = AdminContent::parameter($resource);
foreach ([
['get', 'reviews', 'thread'],
['post', 'comments', 'comment'],
['post', 'review', 'request'],
['post', 'return', 'return'],
['post', 'publish', 'publish'],
['post', 'unpublish', 'unpublish'],
] as [$method, $suffix, $action]) {
Route::{$method}("{$resource}/{{$parameter}}/{$suffix}", [ReviewController::class, $action])
->defaults('content', $resource)
->name("{$resource}.review.{$action}");
}
}
foreach (array_keys(AdminContent::PREVIEWABLE) as $resource) {
$parameter = AdminContent::parameter($resource);
Route::post("{$resource}/{{$parameter}}/preview", [PreviewController::class, 'store'])
->defaults('content', $resource)
->name("{$resource}.preview");
}
// The library. A file is uploaded here and then referenced from a // The library. A file is uploaded here and then referenced from a
// record by its path; it is never edited, only processed again. // record by its path; it is never edited, only processed again.
Route::get('media', [MediaController::class, 'index'])->name('media.index'); Route::get('media', [MediaController::class, 'index'])->name('media.index');

View File

@@ -15,6 +15,14 @@ trait FakesPireon
{ {
protected string $pireonServer = 'https://oauth.pireon.test'; protected string $pireonServer = 'https://oauth.pireon.test';
/**
* Identity payloads by bearer token, for every user a test has signed in
* through the API.
*
* @var array<string, array<string, mixed>>
*/
protected array $pireonIdentities = [];
/** /**
* @param array<string, mixed> $identity Overrides for the GET /api/user payload. * @param array<string, mixed> $identity Overrides for the GET /api/user payload.
* @param array<string, mixed> $token Overrides for the POST /oauth/token payload. * @param array<string, mixed> $token Overrides for the POST /oauth/token payload.
@@ -74,20 +82,39 @@ trait FakesPireon
*/ */
protected function apiHeadersFor(User $user): array protected function apiHeadersFor(User $user): array
{ {
Http::fake([ $token = 'token-for-'.$user->oauth_id;
"{$this->pireonServer}/api/user" => Http::response([
'id' => $user->oauth_id, // Answered by token rather than with one fixed payload: the first
'name' => $user->name, // `Http::fake` for a URL wins, so a test that signs in a second person
'email' => $user->email, // would otherwise keep being told it was the first.
'is_active' => true, if ($this->pireonIdentities === []) {
'roles' => ['user'], Http::fake([
'app_roles' => [], "{$this->pireonServer}/api/user" => function ($request) {
'approved_at' => '2026-01-01T00:00:00+00:00', $presented = str_replace('Bearer ', '', $request->header('Authorization')[0] ?? '');
]),
]); return isset($this->pireonIdentities[$presented])
? Http::response($this->pireonIdentities[$presented])
: Http::response(['message' => 'Unauthenticated.'], 401);
},
]);
}
$this->pireonIdentities[$token] = [
'id' => $user->oauth_id,
'name' => $user->name,
'email' => $user->email,
'is_active' => true,
'roles' => ['user'],
'app_roles' => [],
'approved_at' => '2026-01-01T00:00:00+00:00',
];
// The guard remembers whoever it resolved last; a test that switches
// people halfway has to make it ask again.
$this->app['auth']->forgetGuards();
return [ return [
'Authorization' => 'Bearer token-for-'.$user->oauth_id, 'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json', 'Accept' => 'application/json',
]; ];
} }

View File

@@ -114,6 +114,7 @@ class AdminApiContractTest extends TestCase
$repository = Repository::factory()->create(); $repository = Repository::factory()->create();
Setting::set(Maintenance::KEY, ['enabled' => false]); Setting::set(Maintenance::KEY, ['enabled' => false]);
$media = Media::factory()->create(); $media = Media::factory()->create();
$page->comment('Primera nota', $this->caller);
// One request, one documented path — the URL a real caller hits and // One request, one documented path — the URL a real caller hits and
// the path key that describes it, side by side rather than derived // the path key that describes it, side by side rather than derived
@@ -153,6 +154,8 @@ class AdminApiContractTest extends TestCase
'maintenance' => ['/v1/maintenance', '/v1/maintenance'], 'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
'media' => ['/v1/media', '/v1/media'], 'media' => ['/v1/media', '/v1/media'],
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'], 'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
'revisions' => ["/v1/projects/{$project->id}/revisions", '/v1/projects/{project}/revisions'],
'review thread' => ["/v1/pages/{$page->id}/reviews", '/v1/pages/{page}/reviews'],
'users' => ['/v1/users', '/v1/users'], 'users' => ['/v1/users', '/v1/users'],
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
'me' => ['/v1/me', '/v1/me'], 'me' => ['/v1/me', '/v1/me'],

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\PageStatus;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\User;
use App\Support\PreviewLink;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* History, review, publication and preview links through the admin API.
*/
class ReviewAndHistoryApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Notification::fake();
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
#[Test]
public function the_history_lists_each_change_with_who_made_it(): void
{
$editor = $this->actingWithRole('editor');
$project = Project::factory()->create(['status' => 'active']);
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])->assertOk();
$this->getJson("/api/v1/projects/{$project->id}/revisions")
->assertOk()
->assertJsonPath('data.0.event', 'updated')
->assertJsonPath('data.0.author.id', $editor->id)
->assertJsonPath('data.0.changes.status.new', 'paused');
}
#[Test]
public function a_change_is_undone_and_the_undoing_is_recorded(): void
{
$this->actingWithRole('editor');
$service = Service::factory()->create(['tagline' => ['es' => 'Antes']]);
$this->patchJson("/api/v1/services/{$service->id}", ['tagline' => ['es' => 'Después']])->assertOk();
$revision = $service->revisions()->first();
$this->postJson("/api/v1/revisions/{$revision->id}/restore")
->assertOk()
->assertJsonPath('data.changes.tagline.new.es', 'Antes');
$this->assertSame('Antes', $service->fresh()->getTranslation('tagline', 'es'));
$this->assertSame(3, $service->revisions()->count());
}
#[Test]
public function undoing_a_publication_takes_the_publish_permission(): void
{
$page = Page::factory()->draft()->create();
$this->actingAs($editor = User::factory()->create()->syncRoles(['editor']));
$page->update(['status' => PageStatus::Published, 'published_at' => now()]);
$this->actingWithRole('writer');
$this->postJson('/api/v1/revisions/'.$page->revisions()->first()->id.'/restore')
->assertUnprocessable()
->assertJsonValidationErrors('revision');
$this->assertSame(PageStatus::Published, $page->fresh()->status);
}
#[Test]
public function a_writer_asks_for_review_and_an_editor_sees_it_waiting(): void
{
$writer = $this->actingWithRole('writer');
$page = Page::factory()->draft()->create();
$this->postJson("/api/v1/pages/{$page->id}/review", ['note' => 'Lista para revisar.'])
->assertOk()
->assertJsonPath('data.awaiting_review', true)
->assertJsonPath('data.requested_by.id', $writer->id);
$this->assertSame(PageStatus::Review, $page->fresh()->status);
$this->postJson("/api/v1/pages/{$page->id}/review")->assertUnprocessable();
$this->actingWithRole('editor');
$this->getJson('/api/v1/reviews')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.type', 'pages')
->assertJsonPath('data.0.id', $page->id);
$this->getJson("/api/v1/pages/{$page->id}/reviews")
->assertJsonPath('data.0.event', 'requested')
->assertJsonPath('data.0.body', 'Lista para revisar.');
}
#[Test]
public function an_editor_returns_it_with_a_reason(): void
{
$this->actingAs(User::factory()->create()->syncRoles(['writer']));
$page = Page::factory()->draft()->create();
$page->requestReview();
$this->actingWithRole('editor');
$this->postJson("/api/v1/pages/{$page->id}/return", [])->assertUnprocessable();
$this->postJson("/api/v1/pages/{$page->id}/return", ['note' => 'Falta la imagen.'])
->assertOk()
->assertJsonPath('data.awaiting_review', false);
$this->assertSame(PageStatus::Draft, $page->fresh()->status);
}
#[Test]
public function publishing_later_answers_the_review_now(): void
{
$this->actingAs(User::factory()->create()->syncRoles(['writer']));
$project = Project::factory()->draft()->create();
$project->requestReview();
$this->actingWithRole('editor');
$at = now()->addDays(3)->startOfMinute();
$this->postJson("/api/v1/projects/{$project->id}/publish", ['at' => $at->toIso8601String()])
->assertOk()
->assertJsonPath('data.published', false)
->assertJsonPath('data.awaiting_review', false)
->assertJsonPath('data.published_at', $at->toIso8601String());
$this->postJson("/api/v1/projects/{$project->id}/unpublish")
->assertOk()
->assertJsonPath('data.published_at', null);
}
#[Test]
public function a_writer_cannot_publish_or_return(): void
{
$this->actingWithRole('writer');
$service = Service::factory()->create(['is_published' => false]);
$this->postJson("/api/v1/services/{$service->id}/publish")->assertForbidden();
$this->postJson("/api/v1/services/{$service->id}/return", ['note' => 'x'])->assertForbidden();
$this->assertFalse($service->fresh()->is_published);
}
#[Test]
public function preview_links_open_that_draft_only(): void
{
config(['site.url' => 'https://pireon.test']);
$this->actingWithRole('writer');
$page = Page::factory()->draft()->create();
$links = $this->postJson("/api/v1/pages/{$page->id}/preview")
->assertCreated()
->json('data.links');
$this->assertArrayHasKey('es', $links);
$this->assertStringStartsWith('https://pireon.test/', $links['es']);
parse_str((string) parse_url($links['es'], PHP_URL_QUERY), $query);
$this->assertSame(['type' => 'page', 'id' => $page->id, 'locale' => 'es'], PreviewLink::open($query['preview']));
}
#[Test]
public function comments_join_the_thread(): void
{
$this->actingWithRole('editor');
$page = Page::factory()->create();
$this->postJson("/api/v1/pages/{$page->id}/comments", ['body' => '¿Revisamos el título?'])
->assertCreated()
->assertJsonPath('data.event', 'commented');
}
}