Preserve published territories during private editorial review

This commit is contained in:
2026-11-14 09:03:05 +02:00
parent 3c4c2599f7
commit b3d4b7abe6
12 changed files with 1163 additions and 11 deletions
@@ -6,6 +6,7 @@ use App\Models\User;
use App\Services\ContentManagement;
use App\Services\DocumentManagementAccess;
use App\Services\PortalEditorialWorkflow;
use App\Services\TerritorialRegistry;
use App\Services\VerifiedDocumentBinary;
use App\Support\CheckedFile;
use Illuminate\Http\Request;
@@ -43,7 +44,8 @@ final class PortalEditorialWorkflowController extends Controller
$items = $query->orderByDesc('e.updated_at')->paginate(25, ['r.id', DB::raw("e.payload->'resource'->>'title' AS title"), 'r.document_type', 'r.published', 'e.id as edition_id', 'e.revision_no', 'e.state', 'e.legal_required', 'u.name as responsible'])->withQueryString();
$destinations = app(ContentManagement::class)->access($actor);
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null,
'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor), 'demographyUrl' => $destinations['demography']['index'] ?? null, 'demographyPending' => $workflow->demographyPending($actor)];
'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor), 'demographyUrl' => $destinations['demography']['index'] ?? null, 'demographyPending' => $workflow->demographyPending($actor),
'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null];
return $this->screen($request, 'management/editorial-workflow', $data);
}
@@ -5,11 +5,30 @@ namespace App\Http\Controllers;
use App\Services\TerritorialRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class TerritorialRegistryController extends Controller
{
public function editor(Request $request, TerritorialRegistry $registry): Response
{
abort_unless($registry->editorialAvailable(), 404);
$actor = $request->user();
$roles = array_values(array_filter(['editors', 'reviewers', 'publishers'], fn ($role) => in_array(strtolower($actor->email), config('territorial.'.$role, []), true)));
return Inertia::render('management/territorial-editorial', ['roles' => $roles, 'actorId' => $actor->id])->toResponse($request);
}
public function preview(Request $request, TerritorialRegistry $registry, string $id): JsonResponse
{
abort_unless($registry->editorialAvailable(), 404);
return response()->json($registry->preview($id));
}
public function sources(Request $request): JsonResponse
{
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]);
@@ -17,13 +36,25 @@ class TerritorialRegistryController extends Controller
return response()->json(DB::table('geo_sources')->orderBy('id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
}
public function drafts(Request $request): JsonResponse
public function drafts(Request $request, TerritorialRegistry $registry): JsonResponse
{
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]);
return response()->json(DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id')->whereNull('g.deleted_at')
$page = DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id')->whereNull('g.deleted_at')
->select(['g.id', 'g.name', 'g.code', 'g.location_type', 'g.parent_id', 'g.version_no', 'p.source_id', 'p.status', 'p.valid_from', 'p.valid_to', 'p.editor_id', 'p.reviewer_id', 'p.publisher_id'])
->orderBy('g.id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
->orderBy('g.id')->cursorPaginate($data['limit'] ?? 20)->withQueryString();
if ($registry->editorialAvailable()) {
abort_unless($page instanceof CursorPaginator, 503, 'Paginação territorial indisponível.');
$page->through(function ($row) use ($registry) {
$state = $registry->working($row->id);
return [...$state['location'], ...$state['profile'], 'id' => $row->id,
'public_version_no' => $state['public_version_no'], 'fresh' => $state['fresh'],
'private_revision' => $state['_editorial']['private'] ?? false];
});
}
return response()->json($page);
}
public function index(Request $request, TerritorialRegistry $registry, ?string $collection = null): JsonResponse
@@ -74,7 +105,8 @@ class TerritorialRegistryController extends Controller
{
$id = $registry->save($request->user(), $request->all(), $id);
return response()->json(['id' => $id, 'status' => 'draft', 'version_no' => DB::table('geo_locations')->where('id', $id)->value('version_no')]);
return response()->json(['id' => $id, 'status' => 'draft', 'version_no' => $registry->editorialAvailable()
? $registry->working($id)['location']['version_no'] : DB::table('geo_locations')->where('id', $id)->value('version_no')]);
}
public function source(Request $request): JsonResponse
@@ -95,9 +127,15 @@ class TerritorialRegistryController extends Controller
public function transition(Request $request, TerritorialRegistry $registry, string $id): JsonResponse
{
$data = $request->validate(['action' => ['required', 'in:pending_review,approved,published,rejected,archived'],
$data = $request->validate(['action' => ['required', 'in:pending_review,in_review,approved,published,rejected,archived,withdrawn'],
'version_no' => ['required', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000']]);
$registry->ensureWriteMode($id);
if ($registry->editorialAvailable() && $registry->hasEditorialHistory($id)) {
return response()->json($registry->transitionEditorial($request->user(), $id, $data));
}
abort_if(in_array($data['action'], ['in_review', 'withdrawn'], true), 422);
return DB::transaction(function () use ($request, $registry, $id, $data) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
+10 -1
View File
@@ -2,6 +2,8 @@
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use App\Services\TerritorialRegistry;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
@@ -24,9 +26,16 @@ class TerritorialRequest
} else {
abort_unless($request->user()->can('manage-portal') && $request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
$role = $request->is('*/transitions') ? match ($request->input('action')) {
'approved', 'rejected' => 'reviewers', 'published', 'archived', 'revoked' => 'publishers', default => 'editors',
'in_review', 'approved', 'rejected' => 'reviewers', 'published', 'archived', 'withdrawn', 'revoked' => 'publishers', default => 'editors',
} : 'editors';
abort_unless(in_array(strtolower($request->user()->email), config('territorial.'.$role, []), true), 403, 'Função editorial não atribuída.');
if ($request->is('api/v1/geo/territories*')) {
app(TerritorialRegistry::class)->ensureWriteMode($request->route('id'));
if (config('editorial_workflow.territorial_versions')) {
abort_unless(app(PortalProfileAccess::class)->mfa($request->user())
&& ! app(PortalProfileAccess::class)->has($request->user(), 'auditor'), 403, 'MFA válido necessário.');
}
}
$key = $request->header('Idempotency-Key', '');
abort_unless(Str::isUuid($key), 422, 'Idempotency-Key UUID obrigatório.');
$scope = $request->method().':'.$request->path();
+300 -4
View File
@@ -3,9 +3,12 @@
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
@@ -36,8 +39,12 @@ final class TerritorialRegistry
'location_type' => ['required', 'in:'.implode(',', array_keys(self::PARENTS))],
'parent_id' => ['nullable', 'uuid', 'exists:geo_locations,id'], 'source_id' => ['required', 'uuid', 'exists:geo_sources,id'],
'valid_from' => ['required', 'date_format:Y-m-d'], 'valid_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000'], 'confirm_baseline_change' => ['sometimes', 'boolean'],
])->validate();
$this->ensureWriteMode($id);
if ($this->editorialAvailable()) {
return $this->saveEditorial($actor, $data, $id);
}
return DB::transaction(function () use ($actor, $data, $id) {
// Serialise hierarchy changes to prevent concurrent parent cycles and duplicate paths.
@@ -68,12 +75,301 @@ final class TerritorialRegistry
});
}
public function snapshot(string $id, int $version, User $actor, string $reason): void
/** @param array<string,mixed>|null $proposal */
public function snapshot(string $id, int $version, User $actor, string $reason, ?array $proposal = null): string
{
$snapshot = json_encode(['location' => DB::table('geo_locations')->find($id),
$snapshot = json_encode($proposal ?? ['location' => DB::table('geo_locations')->find($id),
'profile' => DB::table('geo_territory_profiles')->where('geo_location_id', $id)->first()], JSON_THROW_ON_ERROR);
DB::table('geo_territory_revisions')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $id,
$revision = (string) Str::uuid();
DB::table('geo_territory_revisions')->insert(['id' => $revision, 'geo_location_id' => $id,
'version_no' => $version, 'actor_id' => $actor->id, 'reason' => Crypt::encryptString($reason),
'snapshot' => Crypt::encryptString($snapshot), 'checksum' => hash('sha256', $snapshot)]);
return $revision;
}
private const EDITORIAL_SOURCE = 'territorial-editorial-v1';
public function editorialAvailable(): bool
{
return (bool) config('editorial_workflow.territorial_versions') && (bool) config('territorial.enabled') && Schema::hasTable('geo_territory_revisions') && Schema::hasTable('audit_events');
}
public function ensureWriteMode(?string $id): void
{
if (config('editorial_workflow.territorial_versions')) {
abort_unless($this->editorialAvailable(), 503, 'Circuito territorial ainda não disponível.');
} elseif ($id) {
abort_if(DB::table('audit_events')->where('source_system', self::EDITORIAL_SOURCE)->where('entity_id', $id)->exists(), 503, 'Reactive o circuito para conservar a revisão territorial privada.');
}
}
public function hasEditorialHistory(string $id): bool
{
return DB::table('audit_events')->where('source_system', self::EDITORIAL_SOURCE)->where('entity_id', $id)->exists();
}
/** @return array<string,mixed> */
public function preview(string $id): array
{
$state = $this->working($id);
$history = DB::table('geo_territory_revisions')->where('geo_location_id', $id)->orderByDesc('version_no')->limit(100)->get()
->map(function ($revision) {
try {
$json = Crypt::decryptString($revision->snapshot);
$reason = Crypt::decryptString($revision->reason);
} catch (DecryptException) {
abort(409, 'Histórico territorial indisponível; preserve o original.');
}
abort_unless(hash_equals(trim($revision->checksum), hash('sha256', $json)), 409, 'Histórico territorial alterado.');
return ['id' => $revision->id, 'version_no' => $revision->version_no, 'actor_id' => $revision->actor_id,
'reason' => $reason, 'snapshot' => json_decode($json, true, 512, JSON_THROW_ON_ERROR)];
});
return ['working' => $state, 'public' => $this->live($id), 'history' => $history];
}
/** @return array<string,mixed> */
private function live(string $id): array
{
$location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->first();
$profile = DB::table('geo_territory_profiles')->where('geo_location_id', $id)->first();
abort_unless($location && $profile, 404);
return ['location' => get_object_vars($location), 'profile' => get_object_vars($profile)];
}
/** @param array<string,mixed> $value */
private function digest(array $value): string
{
return hash('sha256', json_encode(Arr::sortRecursive($value), JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
public function working(string $id): array
{
$live = $this->live($id);
$event = DB::table('audit_events')->where('source_system', self::EDITORIAL_SOURCE)->where('entity_id', $id)->orderByRaw("(after_data->>'version_no')::integer DESC")->first();
if ($event) {
$details = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$revision = DB::table('geo_territory_revisions')->where('id', $details['revision_id'])->where('geo_location_id', $id)->first();
abort_unless($revision && hash_equals(trim($revision->checksum), $details['checksum']), 409, 'Referência de revisão territorial alterada.');
try {
$json = Crypt::decryptString($revision->snapshot);
} catch (DecryptException) {
abort(409, 'Revisão territorial indisponível; conserve o histórico.');
}
abort_unless(hash_equals(trim($revision->checksum), hash('sha256', $json)), 409, 'Conteúdo da revisão territorial alterado.');
$state = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
abort_unless($state['location']['id'] === $id && $state['location']['version_no'] === (int) $revision->version_no, 409);
if ($state['_editorial']['private']) {
$state['public_version_no'] = $live['profile']['status'] === 'published' ? $live['location']['version_no'] : null;
$state['baseline_changed'] = ! hash_equals($state['_editorial']['baseline_hash'], $this->digest($live));
$state['fresh'] = ! $state['baseline_changed'] && hash_equals($state['_editorial']['content_hash'], $this->contentHash($state));
return $state;
}
$live['_editorial'] = $state['_editorial'];
}
$live['public_version_no'] = $live['profile']['status'] === 'published' ? $live['location']['version_no'] : null;
$live['location']['version_no'] = max((int) $live['location']['version_no'], (int) DB::table('geo_territory_revisions')->where('geo_location_id', $id)->max('version_no'));
$live['baseline_changed'] = false;
$live['fresh'] = true;
return $live;
}
/** @param array<string,mixed> $state
* @return array<string,mixed>
*/
private function evidence(array $state): array
{
$source = DB::table('geo_sources')->where('id', $state['profile']['source_id'])->first();
abort_unless($source !== null, 422, 'Fonte indisponível.');
$parents = [];
$parent = $state['location']['parent_id'];
$seen = [$state['location']['id']];
while ($parent) {
abort_if(in_array($parent, $seen, true) || count($seen) > 5, 422, 'Hierarquia territorial incoerente.');
$seen[] = $parent;
$row = DB::table('geo_locations')->where('id', $parent)->whereNull('deleted_at')->first(['id', 'name', 'code', 'parent_id', 'location_type', 'version_no']);
abort_unless($row !== null, 422, 'Ascendente indisponível.');
$parents[] = get_object_vars($row);
$parent = $row->parent_id;
}
return ['source' => Arr::only(get_object_vars($source), ['id', 'name', 'responsible_entity', 'reference', 'license', 'checksum', 'source_version', 'collected_at', 'original_crs']), 'parents' => $parents];
}
/** @param array<string,mixed> $state */
private function contentHash(array $state): string
{
return $this->digest(['location' => Arr::only($state['location'], ['id', 'name', 'code', 'location_type', 'parent_id']),
'profile' => Arr::only($state['profile'], ['source_id', 'valid_from', 'valid_to']),
'contributors' => $state['_editorial']['contributors'], 'evidence' => $this->evidence($state)]);
}
private function editorialActor(User $actor, string $role): void
{
abort_unless($actor->can('manage-portal') && $actor->two_factor_confirmed_at && $actor->two_factor_secret && ! app(PortalProfileAccess::class)->has($actor, 'auditor')
&& in_array(strtolower($actor->email), config('territorial.'.$role, []), true) && app(PortalProfileAccess::class)->mfa($actor), 403, 'Função territorial e MFA válido necessários.');
}
/** @param array<string,mixed> $data */
private function shape(array $data, ?string $id): void
{
$parentType = self::PARENTS[$data['location_type']];
$parent = empty($data['parent_id']) ? null : DB::table('geo_locations')->where('id', $data['parent_id'])->whereNull('deleted_at')->first();
abort_unless($parentType ? $parent && $parent->location_type === $parentType : ! $parent, 422, 'Hierarquia territorial incoerente.');
$duplicate = DB::table('geo_locations')->where('location_type', $data['location_type'])->whereNull('deleted_at')->when($id, fn ($q) => $q->where('id', '<>', $id))
->where(fn ($q) => $q->where('code', $data['code'])->orWhere(fn ($q) => $q->where('parent_id', $data['parent_id'] ?? null)->whereRaw('lower(trim(name))=lower(trim(?))', [$data['name']])))->exists();
abort_if($duplicate, 422, 'Código ou nome no mesmo pai já existente.');
}
/** @param array<string,mixed> $state */
private function appendEditorial(User $actor, string $id, array $state, string $reason, string $action): void
{
unset($state['fresh'], $state['baseline_changed'], $state['public_version_no']);
$version = (int) $state['location']['version_no'];
$revision = $this->snapshot($id, $version, $actor, $reason, $state);
$checksum = DB::table('geo_territory_revisions')->where('id', $revision)->value('checksum');
DB::table('audit_events')->insert(['source_system' => self::EDITORIAL_SOURCE, 'entity_type' => 'geo_locations', 'entity_id' => $id, 'actor_user_id' => $actor->id,
'action' => $action, 'data_classification' => 'restricted', 'after_data' => json_encode(['version_no' => $version, 'revision_id' => $revision, 'checksum' => trim($checksum), 'state' => $state['profile']['status']], JSON_THROW_ON_ERROR)]);
}
/** @param array<string,mixed> $data */
private function saveEditorial(User $actor, array $data, ?string $id): string
{
$this->editorialActor($actor, 'editors');
return DB::transaction(function () use ($actor, $data, $id) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$this->shape($data, $id);
if (! $id) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'name' => $data['name'], 'code' => $data['code'], 'location_type' => $data['location_type'], 'parent_id' => $data['parent_id'] ?? null,
'version_no' => 1, 'data_classification' => 'internal', 'updated_at' => now()]);
DB::table('geo_territory_profiles')->insert(['geo_location_id' => $id, 'source_id' => $data['source_id'], 'valid_from' => $data['valid_from'], 'valid_to' => $data['valid_to'] ?? null, 'status' => 'draft', 'editor_id' => $actor->id]);
$state = $this->live($id);
} else {
DB::table('geo_locations')->where('id', $id)->lockForUpdate()->first();
$state = $this->working($id);
abort_unless((int) $state['location']['version_no'] === (int) $data['version_no'], 409, 'Versão territorial alterada.');
abort_unless($state['location']['location_type'] === $data['location_type'], 422, 'O tipo territorial é imutável.');
abort_if(($state['baseline_changed'] ?? false) && ! ($data['confirm_baseline_change'] ?? false), 409, 'Compare a publicação actual e confirme a reconciliação antes de guardar.');
if (! $this->hasEditorialHistory($id) && ! DB::table('geo_territory_revisions')->where('geo_location_id', $id)->where('version_no', $state['location']['version_no'])->exists()) {
$this->snapshot($id, $state['location']['version_no'], $actor, 'Conservação do estado anterior ao circuito editorial; não constitui nova aprovação.');
}
$state['location']['version_no']++;
}
$private = $state['_editorial']['private'] ?? false;
$contributors = array_values(array_unique([...($private ? $state['_editorial']['contributors'] : []), $actor->id]));
$state['location'] = array_replace($state['location'], Arr::only($data, ['name', 'code', 'location_type', 'parent_id']), ['data_classification' => 'internal']);
$state['profile'] = array_replace($state['profile'], Arr::only($data, ['source_id', 'valid_from', 'valid_to']), ['status' => 'draft', 'editor_id' => $actor->id, 'reviewer_id' => null, 'publisher_id' => null]);
$state['_editorial'] = ['private' => true, 'edition_no' => $state['location']['version_no'], 'contributors' => $contributors, 'approval' => null, 'baseline_hash' => $this->digest($this->live($id)), 'evidence' => $this->evidence($state)];
$state['_editorial']['content_hash'] = $this->contentHash($state);
$this->appendEditorial($actor, $id, $state, $data['reason'], 'draft_saved');
return $id;
});
}
/** @param array<string,mixed> $data
* @return array<string,mixed>
*/
public function transitionEditorial(User $actor, string $id, array $data): array
{
$this->ensureWriteMode($id);
abort_unless($this->editorialAvailable(), 503);
$action = $data['action'];
$role = match ($action) {
'in_review', 'approved', 'rejected' => 'reviewers',
'published', 'archived', 'withdrawn' => 'publishers',
default => 'editors',
};
$this->editorialActor($actor, $role);
return DB::transaction(function () use ($actor, $id, $data, $action) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
DB::table('geo_locations')->where('id', $id)->lockForUpdate()->first();
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->lockForUpdate()->first();
$state = $this->working($id);
abort_unless((int) $state['location']['version_no'] === (int) $data['version_no'], 409, 'Versão territorial alterada.');
if ($action === 'published') {
$evidence = $this->evidence($state);
$parentIds = array_column($evidence['parents'], 'id');
DB::table('geo_locations')->whereIn('id', $parentIds)->orderBy('id')->lockForUpdate()->get();
DB::table('geo_territory_profiles')->whereIn('geo_location_id', $parentIds)->orderBy('geo_location_id')->lockForUpdate()->get();
$sourceIds = DB::table('geo_territory_profiles')->whereIn('geo_location_id', $parentIds)->pluck('source_id')->all();
$sourceIds[] = $state['profile']['source_id'];
DB::table('geo_sources')->whereIn('id', $sourceIds)->orderBy('id')->lockForUpdate()->get();
$state = $this->working($id);
}
$private = $state['_editorial']['private'] ?? false;
$live = $this->live($id);
if ($action === 'withdrawn' || ($action === 'archived' && ! $private)) {
abort_unless($live['profile']['status'] === 'published', 409);
$version = $state['location']['version_no'] + 1;
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->update(['status' => 'archived', 'updated_at' => now()]);
DB::table('geo_locations')->where('id', $id)->update(['data_classification' => 'internal', 'version_no' => $version, 'updated_at' => now()]);
if ($private) {
$state['_editorial']['baseline_hash'] = $this->digest($this->live($id));
} else {
$state = $this->live($id);
$state['_editorial'] = ['private' => false];
}
$state['location']['version_no'] = $version;
$this->appendEditorial($actor, $id, $state, $data['reason'], 'publication_withdrawn');
return ['id' => $id, 'status' => $state['profile']['status'], 'version_no' => $version];
}
abort_unless($private, 409, 'Guarde uma versão privada antes de iniciar o novo circuito.');
$from = ['pending_review' => ['draft', 'rejected'], 'in_review' => ['pending_review'], 'approved' => ['in_review'],
'rejected' => ['pending_review', 'in_review'], 'published' => ['approved'], 'archived' => ['draft', 'pending_review', 'in_review', 'approved', 'rejected']];
abort_unless(isset($from[$action]) && in_array($state['profile']['status'], $from[$action], true), 409);
if (in_array($action, ['pending_review', 'approved', 'published'], true)) {
abort_unless($state['fresh'], 409, 'A publicação, fonte ou hierarquia mudou; reveja a versão.');
}
if ($action === 'pending_review') {
abort_unless($state['profile']['editor_id'] === $actor->id, 403);
}
if (in_array($action, ['in_review', 'approved', 'rejected'], true)) {
abort_if(in_array($actor->id, $state['_editorial']['contributors'], true), 403, 'Revisão independente obrigatória.');
$state['profile']['reviewer_id'] = $actor->id;
}
if ($action === 'approved') {
$state['_editorial']['approval'] = ['reviewer_id' => $actor->id, 'edition_no' => $state['_editorial']['edition_no'],
'content_hash' => $state['_editorial']['content_hash'], 'reason' => $data['reason'], 'at' => now()->toIso8601String()];
}
if ($action === 'published') {
$approval = $state['_editorial']['approval'];
abort_unless(is_array($approval) && $approval['edition_no'] === $state['_editorial']['edition_no']
&& hash_equals($approval['content_hash'], $this->contentHash($state)), 409, 'Aprovação não corresponde a esta versão.');
abort_if(in_array($actor->id, [...$state['_editorial']['contributors'], $approval['reviewer_id']], true), 403, 'Publicação exige terceiro interveniente.');
$reviewer = User::where('id', $approval['reviewer_id'])->first();
abort_unless($reviewer && $reviewer->can('manage-portal') && $reviewer->two_factor_confirmed_at && $reviewer->two_factor_secret
&& ! app(PortalProfileAccess::class)->has($reviewer, 'auditor') && in_array(strtolower($reviewer->email), config('territorial.reviewers', []), true), 409, 'Revisor deixou de ter autoridade.');
abort_unless(DB::table('geo_sources')->where('id', $state['profile']['source_id'])->where('status', 'approved')->exists(), 422, 'Fonte não aprovada.');
abort_if($state['profile']['valid_from'] > today()->toDateString() || ($state['profile']['valid_to'] && $state['profile']['valid_to'] < today()->toDateString()), 422, 'Vigência inválida.');
abort_if($state['location']['parent_id'] && ! $this->published()->where('g.id', $state['location']['parent_id'])->exists(), 422, 'Ascendente não publicado.');
$this->shape($state['location'], $id);
$state['profile']['publisher_id'] = $actor->id;
}
$state['location']['version_no']++;
$state['profile']['status'] = $action;
if ($action === 'published') {
DB::table('geo_locations')->where('id', $id)->update([...Arr::only($state['location'], ['name', 'code', 'location_type', 'parent_id', 'version_no']), 'data_classification' => 'public', 'updated_at' => now()]);
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->update([...Arr::only($state['profile'], ['source_id', 'valid_from', 'valid_to', 'status', 'editor_id', 'reviewer_id', 'publisher_id']), 'updated_at' => now()]);
$state['_editorial']['private'] = false;
}
if ($action === 'archived') {
// Archiving a proposal never withdraws the existing public version.
$state['_editorial']['private'] = false;
}
$this->appendEditorial($actor, $id, $state, $data['reason'], $action);
return ['id' => $id, 'status' => $action, 'version_no' => $state['location']['version_no']];
});
}
}
+1
View File
@@ -6,6 +6,7 @@ return [
'cluster_competitiveness' => env('PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED', false),
'portfolio' => env('PORTAL_EDITORIAL_PORTFOLIO_ENABLED', false),
'demography' => env('PORTAL_EDITORIAL_DEMOGRAPHY_ENABLED', false),
'territorial_versions' => env('PORTAL_EDITORIAL_TERRITORIAL_ENABLED', false),
// Legal designation is explicit; management authority alone is not a legal designation.
'legal_reviewers' => [],
// Required separation is a policy, distinct from the legacy fact "independent_review".
@@ -1,3 +1,15 @@
## 14/11/2026 — revisão territorial privada e preservação da publicação
O registo territorial existente guarda agora as alterações numa revisão privada encriptada, sem mudar `geo_locations` nem o perfil publicado. Reutiliza as revisões e os recibos idempotentes nativos, sem nova tabela ou motor. O percurso é guardar proposta → submeter → iniciar revisão → aprovar → publicar. Aprovar não publica. O editor não pode rever nenhum conteúdo para o qual contribuiu; o publicador é distinto dos contribuidores e do revisor, conservando a separação nativa. Fontes oficiais já aceites não recebem nova exigência de autenticação.
A publicação confere a versão, o conteúdo, a fonte e a hierarquia, bloqueia as linhas de contexto e aplica a alteração numa transacção. Alterações posteriores exigem nova revisão; conflitos não sobrescrevem a proposta. Arquivar uma proposta conserva a publicação anterior. Retirar expressamente a publicação mantém a proposta e a revisão pendentes. O estado anterior fica no histórico, sem fabricar aprovação. Fontes e referências sem perfil territorial nativo não são adoptadas nem modificadas por este recorte.
Gestão: `/management/territorial/editorial`, ligada ao circuito editorial, com lista paginada, proposta, comparação com o nome/estado público, fonte, validade, fundamento e histórico. As APIs e pré-visualizações mantêm autenticação, gestão, MFA, resposta privada e permissões no servidor. As escritas exigem MFA fresco antes de repetir um recibo; desligar o circuito não permite contornar versões privadas existentes. O formulário conserva edições durante erros e impede mudar de registo ou decidir enquanto há alterações por guardar. As fontes disponíveis vêm do catálogo existente; a selecção mostra até 100 fontes, com aviso no limite. Criação de fontes e alterações de hierarquia continuam nas APIs nativas; o ecrã novo edita os conteúdos dos perfis existentes.
Provas em `outputs/editorial-territorial-20261114/`: cinco percursos novos/67 asserções, cinco testes nativos/44 asserções, dois testes de apresentação do novo ecrã; testes frontend afectados, PHPStan, tipos e compilação registados na mesma pasta. Dados sintéticos, transacções revertidas e bases QA isoladas em 55441. Conferência visual humana pendente; nenhuma instalação ou alteração operacional.
Saldo do pacote conjunto: Fase 1, Competitividade, fichas e dados-base/localização vinculados à carteira, novos lotes demográficos e perfis territoriais nativos implementados em QA. Permanecem outras estatísticas, estruturas sem ficha, relações/cadeias e notificações próprias de Competitividade/território. Estes resíduos não são encerrados pelo pacote. A instalação conjunta exige contrato operacional das quatro alterações aditivas de schema, backup/restauro e autorização concreta; as migrações actuais continuam limitadas ao QA. Não executar `migrate` indiscriminadamente nem enviar este ramo a `main` antes dessa preparação.
## 14/11/2026 — publicação editorial dos novos lotes demográficos
O circuito comum integra agora os novos lotes demográficos já importados para consulta privada: preparar revisão → submeter → iniciar revisão → aprovar → publicar. Aprovar a edição não altera o estado nativo `imported`; só a publicação explícita disponibiliza a série às análises existentes. As séries anteriormente `approved` conservam o estado e as observações, sem nova aprovação. Uma fonte nova continua a ser um lote próprio; não é substituída a série anterior. Arquivar uma publicação desactiva esse lote, conservando valores, originais e eventos.
@@ -11132,3 +11132,4 @@ ACT-20261113-EDITORIAL-F1,"Dashboard Colaborativo Fase 1: versões e autorizaç
ACT-20261113-EDITORIAL-CLUSTERS,"Separar aprovação/publicação da análise de Competitividade; preservar publicação, contexto, versões e autoridade; agrupar entrega relacionada","Directiva de continuidade e decisão de pacote conjunto do proprietário, 13/11/2026; Fase 2 já delimitada",Dashboard Colaborativo / Competitividade dos clusters,/management/clusters; /management/clusters/{id}/competitiveness/{action}; Gestão de conteúdos; Circuito editorial,"Extensão do circuito cifrado existente: submissão, revisão, aprovação e publicação distintas; devolução/rejeição/arquivo; versões/contexto; MFA e autoridade efectiva; navegação reutilizada",QA isolado; flag própria desactivada por defeito; sem instalação operacional,"outputs/editorial-clusters-20261113: integração 14/252; regressão Fase 1 11/223; frontend 18; PHPStan, tipos e build aprovados",Recorte implementado em QA; não encerra a Fase 2,Conferência visual; notificações dos clusters; versionamento dos dados-base e outros conteúdos Fase 2; preparação/instalação operacional,Instalação agrupada exige preparar e autorizar schema/contrato da Fase 1 no destino; não bloqueia QA,Aprovar não publica; rascunho/recuperação preservam publicação; alteração do contexto invalida aprovação; independência quando exigida; revogação e conflitos bloqueados; retirar publicação preserva revisão privada,Alta,,,,,Editor e lista existentes com estados distintos; entradas de gestão integradas,"JSON autenticado, MFA e autorizações verificadas no servidor",Histórico cifrado existente; sem novas tabelas ou migrações deste recorte,Histórico recuperado como nova edição; publicação anterior preservada,Publicação só por confirmação distinta após aprovação da edição exacta,,,,Provas sintéticas do delta e regressão afectada; provas anteriores reutilizadas,Conferência visual; notificações clusters e restantes conteúdos Fase 2; instalação conjunta,"ClusterCompetitiveness, audit_events cifrados, gestão de estruturas, PortalProfileAccess, ContentManagement e circuito Fase 1",,Agrupar funcionalidades relacionadas num pacote e instalar em conjunto quando prontas; sem intervenção operacional nesta passagem,Implementação localizada sem migração; pacote Git agrupado,,outputs/editorial-clusters-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,Regressão anterior de Competitividade e Fase 1; oportunidades mantêm provas aceites,"Ciclo editorial de Competitividade, preservação pública, permissões, independência, recuperação e contexto","Sem conferência visual ou instalação operacional; não prova versão editorial dos dados-base, notificações clusters nem restantes conteúdos Fase 2",,Circuito editorial e agrupamento de entregas,Submeter → rever → aprovar → publicar; preservar a publicação e autoridade efectiva,QA demonstrado; entrega agrupada preparada; instalação pendente
ACT-20261113-EDITORIAL-CARTEIRA,Preservar fichas e dados-base/localização associados de projecto âncora/cluster/corredor durante revisão privada; publicação conjunta e pacote agrupado,"Directiva de continuidade e decisão de agrupar funcionalidades relacionadas, 13/11/2026",Dashboard Colaborativo / carteira de investimento,/management/portfolio/{id}; /management/editorial/portfolio/{id}; transições/notificações existentes,Edições privadas na mesma tabela da Fase 1; aprovação/publicação separadas; fonte/contexto por hash; independência de todos os colaboradores; MFA e auditoria; pendências e notificações; dados-base e localização propostos na mesma edição; publicação atómica; conflitos sem sobrescrever,Estrutura e provas apenas em QA 55441; flag própria desactivada por defeito; sem instalação operacional,"outputs/editorial-portfolio-20261113: novos 5/199; Fase 1 11/223; carteira anterior 8/201; notificações oportunidades 1/20; frontend 20; PHPStan/tipos/build aprovados; dados-base novos 6/178, estruturas/território 19/289, frontend actualizado 21; outputs/editorial-structures-20261113",Fichas e dados-base/localização associados implementados em QA; estruturas sem ficha e restantes conteúdos Fase 2 pendentes,"Conferência visual; preparação/instalação operacional; estruturas sem ficha, relações/cadeias e restantes conteúdos Fase 2",Schema/contrato operacional da entrega agrupada exige preparação e autorização concreta; não bloqueia QA,"Guardar/aprovar mantém todos os campos públicos; publicar aplica só edição aprovada; independência, revogação, MFA e versões verificadas no servidor; contexto alterado exige nova decisão; histórico e notificações recuperáveis; dados-base/localização e ficha aplicados atomicamente; precisão do orçamento; conflitos de código e território sem publicação parcial",Alta,,,,,"Editor existente, ecrã de revisão reutilizado, versões/contexto, acesso pelo painel e Gestão de conteúdos",JSON/HTML protegidos pelas mesmas autorizações; leitura privada sem cache,Edições e decisões na tabela comum; migração QA aditiva com exclusividade do sujeito e rollback protegido,Rascunho e versões anteriores recuperados sem substituir publicação; edições deliberadas preservadas,Ficha pública mantida; nova publicação só por operação independente explícita,,,,Cinco percursos de integração novos e regressões afectadas aprovados; não equivale ao fecho do módulo de dados-base,"Estruturas sem ficha, relações/cadeias, estatística/demografia/território; notificações Competitividade; conferência visual e instalação conjunta","PortalEditorialWorkflow, portal_editorial_editions, investment_dossiers, auditoria, perfis/MFA, editor e prévias nativos",,Implementar em QA e agrupar com Fase 1/Competitividade; sem intervenção operacional,Integração aditiva no circuito existente; sem segundo dossier ou motor,,Recibo e provas em outputs/editorial-portfolio-20261113; manual unificado actualizado; dados-base em outputs/editorial-structures-20261113,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,"Regressão Fase 1, carteira anterior e notificações nativas; Competitividade mantém provas anteriores aplicáveis","Preservação pública, versões/contexto, independência, MFA, autoridade, histórico/notificações e bloqueio de bypass",Sem revisão visual ou instalação operacional; recorte de dados-base apenas para entidades com ficha; não encerra estruturas sem ficha/relações nem restantes conteúdos Fase 2,,Carteira e continuidade do circuito editorial,Ficha existente → edição privada → submissão → revisão → aprovação → publicação; consulta pública e recuperação preservadas,QA demonstrado; integrado na candidata agrupada; instalação pendente
ACT-20261114-EDITORIAL-DEMO,Novos lotes demográficos no circuito editorial: aprovação separada da publicação; preservar lotes e valores anteriores; entrega agrupada,"Fase 2 delimitada e directiva de continuidade; decisão de pacote agrupado, 13–14/11/2026",Dashboard Colaborativo / indicadores demográficos,"/management/demography; /management/editorial/demography/{id}; versões, transições e original protegido","Lote nativo privado mantém imported até publicação explícita; edições e decisões comuns; originais, proveniência, dados e territórios por hash; MFA e autoridade existentes; notificações e pendências",QA isolado; flag própria desactivada por defeito; lotes anteriormente approved preservados sem novas decisões,"outputs/editorial-demography-20261114: seis casos iniciais, incluindo 131400 observações/256MB; cinco funcionais reforçados 5/66; regressão 14/52; frontend 24; PHPStan/tipos/build aprovados",Novos lotes implementados em QA; instalação operacional pendente,Conferência visual; preparação/instalação operacional; outras estatísticas e restante saldo Fase 2,Quatro migrações QA do circuito comum e contrato nominal operacional exigem preparação/autorização concreta; não bloqueia QA,Aprovar não publica; original/observações/proveniência alterados impedem publicação e reaprovação silenciosa; contexto territorial exige revisão; publicar conserva séries anteriores; arquivo conserva valores e histórico; MFA/autoridade/independência quando exigida; memória limitada,Alta,,,,,"Gestão nativa e revisão comum, originais, advertências, edições, pendências e notificações",JSON/HTML protegidos; original privado; anti-bypass das operações antigas,Edições na tabela comum e eventos demográficos nativos; observações preservadas,Histórico e fontes recuperáveis sem repetir importação; dados anteriores preservados,A série só aparece nas análises existentes depois da publicação explícita,,,,Ensaios funcionais e de memória do delta com regressão afectada; não é reauditoria dos doze exemplares aceites,"Registo territorial, outras estatísticas e restantes resíduos; conferência visual e instalação conjunta","DemographicImport, DemographicEvidence, PortalEditorialWorkflow, importações/observações/eventos existentes, perfis e MFA",,"Implementar em QA, conservar fontes aceites e agrupar a instalação; sem intervenção operacional",Adaptação no circuito comum; nenhuma nova tabela de observações ou motor paralelo,,outputs/editorial-demography-20261114; recibo de migração QA e manual unificado,Dashboard Colaborativo — pacote conjunto,14/11/2026,,,,,,,,"Regressão demográfica 14/52; Fase 1, carteira e dados-base reaproveitados/repetidos apenas no código afectado","Separação aprovação/publicação; fontes, números, advertências, jurisdição, acesso, memória e preservação histórica",Sem instalação operacional ou conferência visual; ensaio grande é sintético; não encerra outras estatísticas ou todo o registo territorial,,Publicação editorial de indicadores demográficos,"Lote privado → revisão → aprovação → publicação → análise existente → arquivo, sem alterar valores ou lotes anteriores",QA demonstrado; integrado na candidata agrupada; instalação pendente
ACT-20261114-EDITORIAL-TERRITORIO,Revisão territorial privada sem retirar ou substituir a versão pública; pacote editorial conjunto,"Dashboard Colaborativo, Fase 2 delimitada e directiva de continuidade, 13–14/11/2026",Dashboard Colaborativo / registo territorial,/management/territorial/editorial; /api/v1/geo/drafts/{id}; APIs territoriais existentes,Revisões encriptadas nativas; proposta privada; revisão independente e terceiro publicador; publicação atómica e histórico; recibos idempotentes,QA isolado; flag territorial própria desactivada por defeito; sem instalação operacional,"outputs/editorial-territorial-20261114: integração 5/67; regressão nativa 5/44; frontend, PHPStan, tipos e build",Implementado em QA para perfis territoriais nativos; instalação e conferência visual pendentes,Outras estatísticas; referências sem perfil; notificações territoriais; criação de fontes/hierarquia apenas na API nativa,Instalação agrupada e contrato operacional de schema do pacote comum; nenhuma nova migração territorial,Guardar/rever/aprovar preserva publicação; só publicar aplica; exactidão da versão/fontes/hierarquia; independência; retirada conserva revisão; histórico privado; erros preservam edições,Alta,,,,,"Ecrã de revisão, lista paginada, comparação pública, fonte, validade, decisões separadas e histórico",Permissões/MFA no servidor e antes de replay; pré-visualização privada; sem acesso público a propostas,Revisões encriptadas e imutáveis existentes; publicação transaccional; dados públicos preservados,Versão privada e histórico recuperáveis; contador optimista; fontes alteradas invalidam aprovação,Publicação anterior consultável durante a revisão; alteração apenas após decisão expressa,,,,Delta demonstrado com dados sintéticos; provas anteriores reutilizadas,Conferência visual; contrato/schema operacional e instalação conjunta; resíduos explícitos Fase 2,"TerritorialRegistry, fontes/perfis/revisões/recibos nativos, papéis territoriais, MFA, auditoria e Dashboard editorial",,"Continuar em QA sem alterar produção, dados oficiais ou papéis existentes; agrupar entregas compatíveis",Extensão do circuito territorial nativo; sem motor paralelo,,outputs/editorial-territorial-20261114 e manual unificado,Dashboard Colaborativo — pacote conjunto,14/11/2026,,,,,,,,Fontes e lotes demográficos aceites preservados; regressão nativa apenas no código afectado,"Versões pública/privada, aprovação/publicação separadas, idempotência, MFA, independência, conflitos e histórico",Sem conferência visual ou instalação; referências sem perfil e notificações não encerradas,,Versionamento e circuito editorial territorial,"Publicação → proposta privada → revisão → aprovação → publicação → recuperação, preservando dados e histórico",QA demonstrado; candidata agrupada; instalação pendente
Can't render this file because it is too large.
@@ -42,6 +42,7 @@ export default function EditorialWorkflow({
portfolioPending = 0,
demographyUrl,
demographyPending = 0,
territorialUrl,
}: {
resourceAccess: boolean;
opportunitiesUrl: string | null;
@@ -51,6 +52,7 @@ export default function EditorialWorkflow({
portfolioPending?: number;
demographyUrl?: string | null;
demographyPending?: number;
territorialUrl?: string | null;
filters?: { state?: string; kind?: string };
items: { data: Item[]; current_page: number; last_page: number };
notices: {
@@ -76,6 +78,11 @@ export default function EditorialWorkflow({
Demografia: {demographyPending} lotes pendentes
</Link>
)}
{territorialUrl && (
<Link className="underline" href={territorialUrl}>
Conteúdos territoriais
</Link>
)}
{portfolioUrl && (
<Link className="underline" href={portfolioUrl}>
Carteira: {portfolioPending} fichas pendentes
@@ -0,0 +1,493 @@
import { useEffect, useState } from 'react';
import { Link } from '@inertiajs/react';
import { portalRequest, PortalApiError } from '@/lib/portal-api';
import { Field, Management, button, control } from './shared';
type Row = {
id: string;
name: string;
code: string;
status: string;
version_no: number;
private_revision?: boolean;
public_version_no?: number | null;
};
type State = {
location: {
id: string;
name: string;
code: string;
location_type: string;
parent_id: string | null;
version_no: number;
};
profile: {
source_id: string;
valid_from: string;
valid_to: string | null;
status: string;
editor_id: string;
reviewer_id: string | null;
};
fresh: boolean;
baseline_changed: boolean;
public_version_no: number | null;
_editorial?: { private: boolean; contributors?: string[] };
};
type Preview = {
working: State;
public: State;
history: {
id: string;
version_no: number;
reason: string;
snapshot: State;
}[];
};
const titles: Record<string, string> = {
draft: 'Rascunho',
pending_review: 'Submetido',
in_review: 'Em revisão',
approved: 'Aprovado',
published: 'Publicado',
rejected: 'Devolvido com fundamento',
archived: 'Arquivado',
};
export default function TerritorialEditorial({
roles,
actorId,
}: {
roles: string[];
actorId: string;
}) {
const [rows, setRows] = useState<Row[]>([]);
const [next, setNext] = useState<string | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [form, setForm] = useState({
name: '',
code: '',
source_id: '',
valid_from: '',
valid_to: '',
reason: '',
confirm_baseline_change: false,
});
const [sources, setSources] = useState<
{ id: string; name: string; source_version: string }[]
>([]);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [errors, setErrors] = useState<Record<string, string[]>>({});
const fail = (error: unknown) => {
setErrors(error instanceof PortalApiError ? error.errors : {});
setMessage(
error instanceof PortalApiError && error.status === 409
? 'A versão, fonte ou autoridade mudou. Consulte o estado guardado antes de continuar.'
: error instanceof PortalApiError && error.status === 403
? 'Confirme a sua função e renove o MFA na sessão habitual. Os dados guardados mantêm-se.'
: error instanceof Error
? error.message
: 'Operação não confirmada. Consulte o estado guardado antes de repetir.',
);
};
const list = async (
url = '/api/v1/geo/drafts?limit=30',
append = false,
) => {
const data = await portalRequest<{
data: Row[];
next_page_url: string | null;
}>(url);
setRows((current) => (append ? [...current, ...data.data] : data.data));
setNext(data.next_page_url);
};
useEffect(() => {
void Promise.all([
list(),
portalRequest<{ data: typeof sources }>(
'/api/v1/geo/sources?limit=100',
).then((data) => setSources(data.data)),
]).catch(fail);
}, []);
const open = async (id: string) => {
setBusy(true);
setMessage('');
setErrors({});
try {
const data = await portalRequest<Preview>(
`/api/v1/geo/drafts/${id}`,
);
setPreview(data);
const { location, profile } = data.working;
setForm({
name: location.name,
code: location.code,
source_id: profile.source_id,
valid_from: profile.valid_from,
valid_to: profile.valid_to ?? '',
reason: '',
confirm_baseline_change: false,
});
} catch (error) {
fail(error);
} finally {
setBusy(false);
}
};
const write = async (action?: string) => {
if (!preview) return;
setBusy(true);
setErrors({});
setMessage('');
const { location } = preview.working;
try {
await portalRequest(
`/api/v1/geo/territories/${location.id}${action ? '/transitions' : ''}`,
{
method: action ? 'POST' : 'PATCH',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify(
action
? {
action,
version_no: location.version_no,
reason: form.reason,
}
: {
...form,
valid_to: form.valid_to || null,
location_type: location.location_type,
parent_id: location.parent_id,
version_no: location.version_no,
},
),
},
);
await open(location.id);
await list();
setMessage(
action === 'published'
? 'Versão publicada.'
: action === 'withdrawn'
? 'Publicação retirada; proposta privada preservada.'
: 'Operação guardada.',
);
} catch (error) {
fail(error);
} finally {
setBusy(false);
}
};
const state = preview?.working;
const changed = Boolean(
state &&
(form.name !== state.location.name ||
form.code !== state.location.code ||
form.source_id !== state.profile.source_id ||
form.valid_from !== state.profile.valid_from ||
form.valid_to !== (state.profile.valid_to ?? '')),
);
const contributor = state?._editorial?.contributors?.includes(actorId);
const canReview = roles.includes('reviewers') && !contributor;
const canPublish =
roles.includes('publishers') &&
!contributor &&
state?.profile.reviewer_id !== actorId;
const actionButton = (action: string, title: string, allowed: boolean) =>
allowed && (
<button
key={action}
type="button"
className={button}
disabled={
busy ||
form.reason.trim().length < 10 ||
Boolean(action && changed)
}
onClick={() => void write(action)}
>
{title}
</button>
);
return (
<Management title="Revisão dos conteúdos territoriais">
<Link href="/management/editorial" className="underline">
Circuito editorial
</Link>
<p>
Edite e reveja uma proposta privada. A versão pública mantém-se
até à publicação expressa.
</p>
{message && <p role="status">{message}</p>}
<ul className="space-y-2">
{rows.map((row) => (
<li key={row.id}>
<button
className="text-left underline"
disabled={busy || changed}
onClick={() => void open(row.id)}
>
{row.name} · {titles[row.status] ?? row.status} ·
versão {row.version_no}
{row.private_revision ? ' · proposta privada' : ''}
</button>
</li>
))}
</ul>
{next && (
<button
className={button}
disabled={busy}
onClick={() => {
setBusy(true);
void list(next, true)
.catch(fail)
.finally(() => setBusy(false));
}}
>
Ver seguintes
</button>
)}
{state && preview && (
<section
className="space-y-4 rounded-lg border p-4"
aria-label="Proposta territorial"
>
<h2 className="text-xl font-semibold">
{state.location.name} —{' '}
{titles[state.profile.status] ?? state.profile.status}
</h2>
<p>
Versão pública:{' '}
{state.public_version_no ?? 'sem publicação'} ·
proposta: {state.location.version_no}. Publicação
actual: {preview.public.location.name} (
{titles[preview.public.profile.status]}).
</p>
{!state.fresh && (
<p role="alert">
O contexto foi alterado. Compare a publicação actual
e reveja os dados antes de guardar e pedir nova
aprovação.
</p>
)}
<fieldset
disabled={busy || !roles.includes('editors')}
className="grid gap-4 sm:grid-cols-2"
>
<Field
label="Designação"
error={errors.name?.join(' ')}
>
<input
className={control}
value={form.name}
onChange={(event) =>
setForm({
...form,
name: event.target.value,
})
}
/>
</Field>
<Field label="Código" error={errors.code?.join(' ')}>
<input
className={control}
value={form.code}
onChange={(event) =>
setForm({
...form,
code: event.target.value,
})
}
/>
</Field>
<Field
label="Fonte e versão"
error={errors.source_id?.join(' ')}
>
<select
className={control}
value={form.source_id}
onChange={(event) =>
setForm({
...form,
source_id: event.target.value,
})
}
>
{!sources.some(
(source) => source.id === form.source_id,
) && (
<option value={form.source_id}>
Fonte actual — {form.source_id}
</option>
)}
{sources.map((source) => (
<option key={source.id} value={source.id}>
{source.name} · {source.source_version}
</option>
))}
</select>
</Field>
<Field
label="Aplicável desde"
error={errors.valid_from?.join(' ')}
>
<input
type="date"
className={control}
value={form.valid_from}
onChange={(event) =>
setForm({
...form,
valid_from: event.target.value,
})
}
/>
</Field>
<Field
label="Aplicável até (opcional)"
error={errors.valid_to?.join(' ')}
>
<input
type="date"
className={control}
value={form.valid_to}
onChange={(event) =>
setForm({
...form,
valid_to: event.target.value,
})
}
/>
</Field>
</fieldset>
{sources.length === 100 && (
<p>
São apresentadas as primeiras 100 fontes do catálogo
existente.
</p>
)}
{state.baseline_changed && (
<label className="flex gap-2">
<input
type="checkbox"
checked={form.confirm_baseline_change}
onChange={(event) =>
setForm({
...form,
confirm_baseline_change:
event.target.checked,
})
}
/>
Conferi a publicação alterada e reconciliei esta
proposta.
</label>
)}
<Field
label="Fundamento da operação (pelo menos 10 caracteres)"
error={errors.reason?.join(' ')}
>
<textarea
className={control}
value={form.reason}
onChange={(event) =>
setForm({ ...form, reason: event.target.value })
}
/>
</Field>
{changed && (
<p>
Guarde as alterações antes de efectuar uma decisão
sobre a versão.
</p>
)}
<div className="flex flex-wrap gap-3">
{actionButton(
'',
'Guardar proposta privada',
roles.includes('editors'),
)}
{actionButton(
'pending_review',
'Submeter à revisão',
state._editorial?.private === true &&
['draft', 'rejected'].includes(
state.profile.status,
) &&
state.profile.editor_id === actorId &&
roles.includes('editors') &&
state.fresh,
)}
{actionButton(
'in_review',
'Iniciar revisão',
state.profile.status === 'pending_review' &&
canReview,
)}
{actionButton(
'approved',
'Aprovar esta versão',
state.profile.status === 'in_review' &&
canReview &&
state.fresh,
)}
{actionButton(
'rejected',
'Devolver com fundamento',
['pending_review', 'in_review'].includes(
state.profile.status,
) && canReview,
)}
{actionButton(
'published',
'Publicar versão aprovada',
state.profile.status === 'approved' &&
canPublish &&
state.fresh,
)}
{actionButton(
'archived',
state._editorial?.private
? 'Arquivar proposta privada'
: 'Arquivar publicação',
roles.includes('publishers') &&
(state._editorial?.private === true ||
preview.public.profile.status ===
'published'),
)}
{actionButton(
'withdrawn',
'Retirar publicação actual',
roles.includes('publishers') &&
preview.public.profile.status === 'published' &&
state._editorial?.private === true,
)}
</div>
<details>
<summary className="cursor-pointer underline">
Histórico (últimas 100 operações)
</summary>
<ol>
{preview.history.map((item) => (
<li key={item.id}>
Versão {item.version_no} ·{' '}
{item.snapshot.location.name} ·{' '}
{titles[item.snapshot.profile.status] ??
item.snapshot.profile.status}
: {item.reason}
</li>
))}
</ol>
</details>
</section>
)}
</Management>
);
}
+4
View File
@@ -6,6 +6,9 @@ use App\Http\Middleware\RequireZoneMfa;
use App\Http\Middleware\TerritorialRequest;
use Illuminate\Support\Facades\Route;
Route::get('/management/territorial/editorial', [TerritorialRegistryController::class, 'editor'])
->middleware(['auth', 'verified', 'can:manage-portal', RequireZoneMfa::class, PrivateInvestmentResponse::class, TerritorialRequest::class]);
Route::prefix('api/v1/geo')->middleware(['auth', 'verified', PrivateInvestmentResponse::class, 'throttle:api', TerritorialRequest::class])->group(function () {
Route::get('/search', [TerritorialRegistryController::class, 'index']);
Route::get('/{collection}', [TerritorialRegistryController::class, 'index'])->whereIn('collection', ['countries', 'provinces', 'districts', 'admin-posts', 'localities']);
@@ -13,6 +16,7 @@ Route::prefix('api/v1/geo')->middleware(['auth', 'verified', PrivateInvestmentRe
Route::middleware(['can:manage-portal', RequireZoneMfa::class])->group(function () {
Route::get('/sources', [TerritorialRegistryController::class, 'sources']);
Route::get('/drafts', [TerritorialRegistryController::class, 'drafts']);
Route::get('/drafts/{id}', [TerritorialRegistryController::class, 'preview'])->whereUuid('id');
Route::post('/sources', [TerritorialRegistryController::class, 'source']);
Route::post('/territories', [TerritorialRegistryController::class, 'save']);
Route::patch('/territories/{id}', [TerritorialRegistryController::class, 'save'])->whereUuid('id');
@@ -0,0 +1,141 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\TerritorialRegistry;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class TerritorialEditorialWorkflowTest extends TestCase
{
use AuthenticatesQaActors, DatabaseTransactions;
private User $editor;
private User $reviewer;
private User $publisher;
private string $id;
private array $fields;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
[$this->editor,$this->reviewer,$this->publisher] = User::factory()->count(3)->create()->all();
config(['territorial.enabled' => true, 'editorial_workflow.territorial_versions' => true, 'portal.profile_access_enabled' => false,
'portal.managers' => [$this->editor->email, $this->reviewer->email, $this->publisher->email],
'territorial.editors' => [$this->editor->email], 'territorial.reviewers' => [$this->reviewer->email], 'territorial.publishers' => [$this->publisher->email]]);
$this->actingAsWithMfa($this->reviewer);
$this->actingAsWithMfa($this->publisher);
$this->actingAsWithMfa($this->editor);
$source = (string) Str::uuid();
$this->id = (string) Str::uuid();
$code = 'QA-'.Str::random(12);
DB::table('geo_sources')->insert(['id' => $source, 'name' => 'Synthetic source', 'reference' => 'Synthetic QA only', 'checksum' => str_repeat('c', 64), 'source_version' => 'qa', 'collected_at' => today(), 'status' => 'approved', 'responsible_entity' => 'Synthetic entity', 'license' => 'Synthetic licence', 'editor_id' => $this->editor->id, 'reviewer_id' => $this->reviewer->id]);
DB::table('geo_locations')->insert(['id' => $this->id, 'name' => 'Public original', 'code' => $code, 'location_type' => 'country', 'data_classification' => 'public', 'version_no' => 1]);
DB::table('geo_territory_profiles')->insert(['geo_location_id' => $this->id, 'source_id' => $source, 'valid_from' => '2020-01-01', 'status' => 'published', 'editor_id' => $this->editor->id, 'reviewer_id' => $this->reviewer->id, 'publisher_id' => $this->publisher->id]);
$this->fields = ['name' => 'Private proposal', 'code' => $code, 'location_type' => 'country', 'parent_id' => null, 'source_id' => $source, 'valid_from' => '2020-01-01', 'valid_to' => null, 'version_no' => 1, 'reason' => 'Synthetic editorial proposal'];
}
private function save(int $version = 1): void
{
$this->actingAsWithMfa($this->editor)->patchJson('/api/v1/geo/territories/'.$this->id, [...$this->fields, 'version_no' => $version], ['Idempotency-Key' => (string) Str::uuid()])->assertOk()->assertJsonPath('version_no', $version + 1);
}
private function action(string $action, int $version, int $status = 200): void
{
$actor = match ($action) {
'pending_review' => $this->editor,'in_review','approved','rejected' => $this->reviewer,default => $this->publisher
};
$this->actingAsWithMfa($actor)->postJson('/api/v1/geo/territories/'.$this->id.'/transitions', ['action' => $action, 'version_no' => $version, 'reason' => 'Synthetic reason for '.$action], ['Idempotency-Key' => (string) Str::uuid()])->assertStatus($status);
}
public function test_publication_is_preserved_through_exact_private_review_until_explicit_publication(): void
{
$before = DB::table('geo_locations')->find($this->id);
$profile = DB::table('geo_territory_profiles')->where('geo_location_id', $this->id)->first();
$this->save();
$this->getJson('/api/v1/geo/drafts?limit=100')->assertOk();
$this->assertSame('Private proposal', app(TerritorialRegistry::class)->working($this->id)['location']['name']);
$this->getJson('/api/v1/geo/drafts/'.$this->id)->assertOk()->assertHeader('Cache-Control', 'no-store, private')
->assertJsonPath('working.location.name', 'Private proposal')->assertJsonPath('public.location.name', 'Public original')->assertJsonCount(2, 'history');
$this->withoutVite();
$this->get('/management/territorial/editorial')->assertOk();
foreach (['pending_review' => 2, 'in_review' => 3, 'approved' => 4] as $action => $version) {
$this->action($action, $version);
}
$this->assertEquals($before, DB::table('geo_locations')->find($this->id));
$this->assertEquals($profile, DB::table('geo_territory_profiles')->where('geo_location_id', $this->id)->first());
$this->assertTrue(app(TerritorialRegistry::class)->published()->where('g.id', $this->id)->exists());
$this->action('published', 5);
$this->assertSame('Private proposal', DB::table('geo_locations')->find($this->id)->name);
$this->assertSame('published', DB::table('geo_territory_profiles')->where('geo_location_id', $this->id)->value('status'));
$this->assertDatabaseHas('geo_territory_revisions', ['geo_location_id' => $this->id, 'version_no' => 6]);
$this->save(6);
$this->action('archived', 7);
$this->assertTrue(app(TerritorialRegistry::class)->published()->where('g.id', $this->id)->exists());
}
public function test_changed_evidence_and_concurrent_edits_cannot_reuse_approval(): void
{
$this->save();
$this->action('pending_review', 2);
$this->action('in_review', 3);
$this->action('approved', 4);
DB::table('geo_sources')->where('id', $this->fields['source_id'])->update(['reference' => 'Changed synthetic source']);
$this->action('published', 5, 409);
$this->assertSame('Public original', DB::table('geo_locations')->find($this->id)->name);
$this->action('rejected', 5, 409);
$this->save(5);
$this->action('published', 6, 409);
config(['editorial_workflow.territorial_versions' => false]);
$this->actingAsWithMfa($this->editor)->patchJson('/api/v1/geo/territories/'.$this->id, [...$this->fields, 'version_no' => 6], ['Idempotency-Key' => (string) Str::uuid()])->assertStatus(503);
}
public function test_explicit_withdrawal_preserves_the_pending_private_review(): void
{
$this->save();
$this->action('pending_review', 2);
$this->action('in_review', 3);
$this->action('approved', 4);
$this->action('withdrawn', 5);
$state = app(TerritorialRegistry::class)->working($this->id);
$this->assertSame('approved', $state['profile']['status']);
$this->assertTrue($state['fresh']);
$this->assertFalse(app(TerritorialRegistry::class)->published()->where('g.id', $this->id)->exists());
$this->action('published', 6);
$this->assertTrue(app(TerritorialRegistry::class)->published()->where('g.id', $this->id)->exists());
}
public function test_replays_recheck_fresh_mfa_and_revoked_reviewer_blocks_publication(): void
{
$key = ['Idempotency-Key' => (string) Str::uuid()];
$this->actingAsWithMfa($this->editor)->patchJson('/api/v1/geo/territories/'.$this->id, $this->fields, $key)->assertOk();
$this->withSession(['portal.mfa_at' => time() - 1000])->patchJson('/api/v1/geo/territories/'.$this->id, $this->fields, $key)->assertForbidden();
$this->action('pending_review', 2);
$this->action('in_review', 3);
$this->action('approved', 4);
config(['territorial.reviewers' => []]);
$this->action('published', 5, 409);
$this->assertSame('Public original', DB::table('geo_locations')->find($this->id)->name);
}
public function test_private_previews_and_review_cannot_be_obtained_by_unassigned_accounts_or_contributors(): void
{
$this->save();
config(['territorial.reviewers' => [$this->reviewer->email, $this->editor->email]]);
$this->action('pending_review', 2);
$this->actingAsWithMfa($this->editor)->postJson('/api/v1/geo/territories/'.$this->id.'/transitions', ['action' => 'in_review', 'version_no' => 3, 'reason' => 'Synthetic independent decision'], ['Idempotency-Key' => (string) Str::uuid()])->assertForbidden();
$outsider = User::factory()->create();
$this->actingAsWithMfa($outsider)->getJson('/api/v1/geo/drafts/'.$this->id)->assertForbidden();
$this->actingAsWithMfa($this->editor)->getJson('/api/v1/geo/territories/country/'.$this->id)->assertOk()->assertJsonPath('territory.name', 'Public original');
}
}
@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const source = fs.readFileSync(
new URL(
'../../resources/js/pages/management/territorial-editorial.tsx',
import.meta.url,
),
'utf8',
);
const code = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
let states = [],
index = 0;
const module = { exports: {} };
new Function('require', 'module', 'exports', code)(
(name) => {
if (name === 'react')
return {
...React,
useState: () => [states[index++], () => {}],
useEffect: () => {},
};
if (name === '@inertiajs/react')
return {
Link: ({ href, children }) =>
React.createElement('a', { href }, children),
};
if (name === '@/lib/portal-api')
return {
portalRequest: () => {
throw new Error('No SSR writes');
},
PortalApiError: Error,
};
if (name === './shared')
return {
Management: ({ children }) =>
React.createElement('main', null, children),
Field: ({ label, children }) =>
React.createElement('label', null, label, children),
button: '',
control: '',
};
return require(name);
},
module,
module.exports,
);
const state = {
location: {
id: 'qa',
name: 'Proposta privada',
code: 'QA',
location_type: 'country',
parent_id: null,
version_no: 5,
},
profile: {
source_id: 'qa-source',
valid_from: '2020-01-01',
valid_to: null,
status: 'approved',
editor_id: 1,
reviewer_id: 2,
},
public_version_no: 1,
fresh: true,
baseline_changed: false,
_editorial: { private: true, contributors: [1] },
};
function render({
roles = ['publishers'],
actorId = 3,
changes = {},
dirty = false,
} = {}) {
const working = { ...state, ...changes };
const form = {
name: dirty ? 'Alteração por guardar' : working.location.name,
code: 'QA',
source_id: 'qa-source',
valid_from: '2020-01-01',
valid_to: '',
reason: 'Decisão sintética QA',
confirm_baseline_change: false,
};
states = [
[],
null,
{
working,
public: {
...state,
location: {
...state.location,
name: 'Designação pública original',
},
profile: { ...state.profile, status: 'published' },
},
history: [],
},
form,
[],
false,
'',
{},
];
index = 0;
return renderToStaticMarkup(
React.createElement(module.exports.default, { roles, actorId }),
);
}
test('territorial approval keeps the public reference and requires a separate publisher', () => {
const html = render();
assert.match(html, /Designação pública original/);
assert.match(html, /Publicar versão aprovada/);
assert.match(html, /Versão pública: 1/);
assert.doesNotMatch(render({ actorId: 2 }), /Publicar versão aprovada/);
assert.doesNotMatch(render({ actorId: 1 }), /Publicar versão aprovada/);
assert.doesNotMatch(
render({ roles: ['editors'], actorId: 1 }),
/Retirar publicação actual/,
);
});
test('stale context and unsaved edits cannot silently enter a publication decision', () => {
assert.doesNotMatch(
render({ changes: { fresh: false } }),
/Publicar versão aprovada/,
);
const dirty = render({ roles: ['editors', 'publishers'], dirty: true });
assert.match(
dirty,
/<button[^>]*disabled=""[^>]*>Publicar versão aprovada<\/button>/,
);
assert.match(dirty, /Guarde as alterações antes/);
assert.match(dirty, /Guardar proposta privada/);
});