From 5b46436641d8ddf4ba1a17abbdb1e927fb4aa24e Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 1 Oct 2026 08:29:48 +0200 Subject: [PATCH] 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 --- README.md | 30 + .../Api/V1/Concerns/ResolvesContent.php | 29 + .../Controllers/Api/V1/PreviewController.php | 43 + .../Controllers/Api/V1/ReviewController.php | 187 ++++ .../Controllers/Api/V1/RevisionController.php | 74 ++ app/Http/Resources/ReviewCommentResource.php | 32 + app/Http/Resources/RevisionResource.php | 33 + app/Models/Concerns/HasRevisions.php | 8 +- app/Support/AdminContent.php | 132 +++ app/Support/Publisher.php | 53 + lang/ca/rules.php | 10 + lang/en/rules.php | 10 + lang/es/rules.php | 10 + public/openapi.yaml | 954 ++++++++++++++++++ routes/api.php | 42 + tests/FakesPireon.php | 51 +- tests/Feature/Api/AdminApiContractTest.php | 3 + tests/Feature/Api/ReviewAndHistoryApiTest.php | 211 ++++ 18 files changed, 1899 insertions(+), 13 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php create mode 100644 app/Http/Controllers/Api/V1/PreviewController.php create mode 100644 app/Http/Controllers/Api/V1/ReviewController.php create mode 100644 app/Http/Controllers/Api/V1/RevisionController.php create mode 100644 app/Http/Resources/ReviewCommentResource.php create mode 100644 app/Http/Resources/RevisionResource.php create mode 100644 app/Support/AdminContent.php create mode 100644 app/Support/Publisher.php create mode 100644 tests/Feature/Api/ReviewAndHistoryApiTest.php diff --git a/README.md b/README.md index a30514e..6df96fe 100644 --- a/README.md +++ b/README.md @@ -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 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, `settings.update` para cambiar y `settings.maintenance` para `site.maintenance` y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo diff --git a/app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php b/app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php new file mode 100644 index 0000000..3d93a41 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Concerns/ResolvesContent.php @@ -0,0 +1,29 @@ +route('content'); + } + + protected function record(Request $request): Model + { + $content = $this->content($request); + + return AdminContent::find($content, $request->route(AdminContent::parameter($content))); + } +} diff --git a/app/Http/Controllers/Api/V1/PreviewController.php b/app/Http/Controllers/Api/V1/PreviewController.php new file mode 100644 index 0000000..9bf6634 --- /dev/null +++ b/app/Http/Controllers/Api/V1/PreviewController.php @@ -0,0 +1,43 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/ReviewController.php b/app/Http/Controllers/Api/V1/ReviewController.php new file mode 100644 index 0000000..b25064d --- /dev/null +++ b/app/Http/Controllers/Api/V1/ReviewController.php @@ -0,0 +1,187 @@ +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 + */ + 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, + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/RevisionController.php b/app/Http/Controllers/Api/V1/RevisionController.php new file mode 100644 index 0000000..9158177 --- /dev/null +++ b/app/Http/Controllers/Api/V1/RevisionController.php @@ -0,0 +1,74 @@ +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()); + } +} diff --git a/app/Http/Resources/ReviewCommentResource.php b/app/Http/Resources/ReviewCommentResource.php new file mode 100644 index 0000000..f50d31e --- /dev/null +++ b/app/Http/Resources/ReviewCommentResource.php @@ -0,0 +1,32 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Http/Resources/RevisionResource.php b/app/Http/Resources/RevisionResource.php new file mode 100644 index 0000000..de1823a --- /dev/null +++ b/app/Http/Resources/RevisionResource.php @@ -0,0 +1,33 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Models/Concerns/HasRevisions.php b/app/Models/Concerns/HasRevisions.php index 3e13582..5cd0b34 100644 --- a/app/Models/Concerns/HasRevisions.php +++ b/app/Models/Concerns/HasRevisions.php @@ -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'; } diff --git a/app/Support/AdminContent.php b/app/Support/AdminContent.php new file mode 100644 index 0000000..8d1903a --- /dev/null +++ b/app/Support/AdminContent.php @@ -0,0 +1,132 @@ + [model, route parameter]. + * + * @var array, 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> + */ + 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 + */ + public const PREVIEWABLE = [ + 'pages' => 'page', + 'projects' => 'project', + 'services' => 'service', + 'calls' => 'call', + ]; + + /** + * @return class-string + */ + 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 + */ + 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 + */ + public static function publicationFields(Model $record): array + { + return self::REVIEWABLE[self::nameOf($record)] ?? []; + } +} diff --git a/app/Support/Publisher.php b/app/Support/Publisher.php new file mode 100644 index 0000000..08dc460 --- /dev/null +++ b/app/Support/Publisher.php @@ -0,0 +1,53 @@ +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(); + } +} diff --git a/lang/ca/rules.php b/lang/ca/rules.php index 4da9f11..fbf0686 100644 --- a/lang/ca/rules.php +++ b/lang/ca/rules.php @@ -29,6 +29,16 @@ return [ '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' => [ 'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.', ], diff --git a/lang/en/rules.php b/lang/en/rules.php index f89a7be..9c630fc 100644 --- a/lang/en/rules.php +++ b/lang/en/rules.php @@ -29,6 +29,16 @@ return [ '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' => [ 'read_only' => 'This setting is written by the application and cannot be edited by hand.', ], diff --git a/lang/es/rules.php b/lang/es/rules.php index c81ef74..60adb83 100644 --- a/lang/es/rules.php +++ b/lang/es/rules.php @@ -29,6 +29,16 @@ return [ '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' => [ 'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.', ], diff --git a/public/openapi.yaml b/public/openapi.yaml index e851442..778a16c 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -130,6 +130,10 @@ tags: description: The collective's git repositories and their synced numbers - name: Settings 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 description: Local user administration - name: Media @@ -2650,6 +2654,879 @@ paths: "403": { $ref: "#/components/responses/Forbidden" } "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: get: tags: [Media] @@ -4323,6 +5200,83 @@ components: until: { 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: type: string enum: [pages, technologies, projects, members, clients, testimonials] diff --git a/routes/api.php b/routes/api.php index 7769c91..e600e9e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,9 +20,12 @@ use App\Http\Controllers\Api\V1\MediaController; use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\MissingUrlController; 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\RedirectController; 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\ServiceController; 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\TokenController; use App\Http\Controllers\Api\V1\UserController; +use App\Support\AdminContent; 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::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 // record by its path; it is never edited, only processed again. Route::get('media', [MediaController::class, 'index'])->name('media.index'); diff --git a/tests/FakesPireon.php b/tests/FakesPireon.php index 46bbec4..ee82e33 100644 --- a/tests/FakesPireon.php +++ b/tests/FakesPireon.php @@ -15,6 +15,14 @@ trait FakesPireon { 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> + */ + protected array $pireonIdentities = []; + /** * @param array $identity Overrides for the GET /api/user payload. * @param array $token Overrides for the POST /oauth/token payload. @@ -74,20 +82,39 @@ trait FakesPireon */ protected function apiHeadersFor(User $user): array { - Http::fake([ - "{$this->pireonServer}/api/user" => Http::response([ - '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', - ]), - ]); + $token = 'token-for-'.$user->oauth_id; + + // Answered by token rather than with one fixed payload: the first + // `Http::fake` for a URL wins, so a test that signs in a second person + // would otherwise keep being told it was the first. + if ($this->pireonIdentities === []) { + Http::fake([ + "{$this->pireonServer}/api/user" => function ($request) { + $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 [ - 'Authorization' => 'Bearer token-for-'.$user->oauth_id, + 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', ]; } diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index 96e1358..0b3917f 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -114,6 +114,7 @@ class AdminApiContractTest extends TestCase $repository = Repository::factory()->create(); Setting::set(Maintenance::KEY, ['enabled' => false]); $media = Media::factory()->create(); + $page->comment('Primera nota', $this->caller); // One request, one documented path — the URL a real caller hits and // 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'], 'media' => ['/v1/media', '/v1/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'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'me' => ['/v1/me', '/v1/me'], diff --git a/tests/Feature/Api/ReviewAndHistoryApiTest.php b/tests/Feature/Api/ReviewAndHistoryApiTest.php new file mode 100644 index 0000000..d9338f9 --- /dev/null +++ b/tests/Feature/Api/ReviewAndHistoryApiTest.php @@ -0,0 +1,211 @@ +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'); + } +}