diff --git a/app/Http/Controllers/PortalEditorialWorkflowController.php b/app/Http/Controllers/PortalEditorialWorkflowController.php index 9aead37b..56ed620a 100644 --- a/app/Http/Controllers/PortalEditorialWorkflowController.php +++ b/app/Http/Controllers/PortalEditorialWorkflowController.php @@ -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); } diff --git a/app/Http/Controllers/TerritorialRegistryController.php b/app/Http/Controllers/TerritorialRegistryController.php index 7524d98c..9ac523c0 100644 --- a/app/Http/Controllers/TerritorialRegistryController.php +++ b/app/Http/Controllers/TerritorialRegistryController.php @@ -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(); diff --git a/app/Http/Middleware/TerritorialRequest.php b/app/Http/Middleware/TerritorialRequest.php index a44479f9..600621ff 100644 --- a/app/Http/Middleware/TerritorialRequest.php +++ b/app/Http/Middleware/TerritorialRequest.php @@ -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(); diff --git a/app/Services/TerritorialRegistry.php b/app/Services/TerritorialRegistry.php index a3f02337..1d25c9fb 100644 --- a/app/Services/TerritorialRegistry.php +++ b/app/Services/TerritorialRegistry.php @@ -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|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 */ + 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 */ + 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 $value */ + private function digest(array $value): string + { + return hash('sha256', json_encode(Arr::sortRecursive($value), JSON_THROW_ON_ERROR)); + } + + /** @return array */ + 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 $state + * @return array + */ + 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 $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 $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 $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 $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 $data + * @return array + */ + 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']]; + }); } } diff --git a/config/editorial_workflow.php b/config/editorial_workflow.php index 6e5f3ff0..86e4b9b2 100644 --- a/config/editorial_workflow.php +++ b/config/editorial_workflow.php @@ -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". diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 56ae5e81..86377b79 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -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. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index bc5db67a..5af14e27 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -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 diff --git a/resources/js/pages/management/editorial-workflow.tsx b/resources/js/pages/management/editorial-workflow.tsx index ed537d8e..52e28ac9 100644 --- a/resources/js/pages/management/editorial-workflow.tsx +++ b/resources/js/pages/management/editorial-workflow.tsx @@ -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 )} + {territorialUrl && ( + + Conteúdos territoriais + + )} {portfolioUrl && ( Carteira: {portfolioPending} fichas pendentes diff --git a/resources/js/pages/management/territorial-editorial.tsx b/resources/js/pages/management/territorial-editorial.tsx new file mode 100644 index 00000000..2d778116 --- /dev/null +++ b/resources/js/pages/management/territorial-editorial.tsx @@ -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 = { + 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([]); + const [next, setNext] = useState(null); + const [preview, setPreview] = useState(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>({}); + 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( + `/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 && ( + + ); + return ( + + + Circuito editorial + +

+ Edite e reveja uma proposta privada. A versão pública mantém-se + até à publicação expressa. +

+ {message &&

{message}

} +
    + {rows.map((row) => ( +
  • + +
  • + ))} +
+ {next && ( + + )} + {state && preview && ( +
+

+ {state.location.name} —{' '} + {titles[state.profile.status] ?? state.profile.status} +

+

+ 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]}). +

+ {!state.fresh && ( +

+ O contexto foi alterado. Compare a publicação actual + e reveja os dados antes de guardar e pedir nova + aprovação. +

+ )} +
+ + + setForm({ + ...form, + name: event.target.value, + }) + } + /> + + + + setForm({ + ...form, + code: event.target.value, + }) + } + /> + + + + + + + setForm({ + ...form, + valid_from: event.target.value, + }) + } + /> + + + + setForm({ + ...form, + valid_to: event.target.value, + }) + } + /> + +
+ {sources.length === 100 && ( +

+ São apresentadas as primeiras 100 fontes do catálogo + existente. +

+ )} + {state.baseline_changed && ( + + )} + +