null, 'province' => 'country', 'district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post']; public function published(): Builder { // Every ancestor must remain public, in force and backed by an approved source. return DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id') ->whereNull('g.deleted_at')->where('g.data_classification', 'public')->where('p.status', 'published') ->whereRaw("NOT EXISTS (WITH RECURSIVE ancestry AS ( SELECT l.id,l.parent_id,l.deleted_at,l.data_classification,ARRAY[l.id] AS path,false AS cycle FROM geo_locations l WHERE l.id=g.id UNION ALL SELECT l.id,l.parent_id,l.deleted_at,l.data_classification,a.path||l.id,l.id=ANY(a.path) FROM geo_locations l JOIN ancestry a ON l.id=a.parent_id WHERE NOT a.cycle ) SELECT 1 FROM ancestry a LEFT JOIN geo_territory_profiles ap ON ap.geo_location_id=a.id LEFT JOIN geo_sources s ON s.id=ap.source_id WHERE a.cycle OR a.deleted_at IS NOT NULL OR a.data_classification<>'public' OR ap.status IS DISTINCT FROM 'published' OR s.status IS DISTINCT FROM 'approved' OR ap.valid_from>CURRENT_DATE OR ap.valid_to $input */ public function save(User $actor, array $input, ?string $id = null): string { abort_unless($actor->can('manage-portal') && $actor->two_factor_confirmed_at && $actor->two_factor_secret, 403); abort_unless(in_array(strtolower($actor->email), config('territorial.editors', []), true), 403); $data = Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], 'code' => ['required', 'string', 'max:100'], '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'], '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. DB::select('SELECT pg_advisory_xact_lock(280926, 150000)'); $current = $id ? DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first() : null; if ($id) { abort_unless($current && DB::table('geo_territory_profiles')->where('geo_location_id', $id)->exists(), 404); abort_unless($current->version_no === $data['version_no'], 409); abort_unless($current->location_type === $data['location_type'], 422, 'O tipo territorial é imutável.'); } $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.'); $id ??= (string) Str::uuid(); $version = $current ? $current->version_no + 1 : 1; DB::table('geo_locations')->updateOrInsert(['id' => $id], ['name' => $data['name'], 'code' => $data['code'], 'location_type' => $data['location_type'], 'parent_id' => $data['parent_id'] ?? null, 'version_no' => $version, 'data_classification' => 'internal', 'updated_at' => now()]); DB::table('geo_territory_profiles')->updateOrInsert(['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, 'reviewer_id' => null, 'publisher_id' => null, 'updated_at' => now()]); $this->snapshot($id, $version, $actor, $data['reason']); return $id; }); } /** @param array|null $proposal */ public function snapshot(string $id, int $version, User $actor, string $reason, ?array $proposal = null): string { $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); $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'); $eventId = (string) Str::uuid(); DB::table('audit_events')->insert(['id' => $eventId, '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)]); app(PortalEditorialNativeNotices::class)->emit($actor, $eventId, $state['_editorial']['contributors'] ?? []); } /** @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']]; }); } }