376 lines
25 KiB
PHP
376 lines
25 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
final class TerritorialRegistry
|
|
{
|
|
public const PARENTS = ['country' => 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<CURRENT_DATE)");
|
|
}
|
|
|
|
/** @param array<string,mixed> $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<string,mixed>|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<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']];
|
|
});
|
|
}
|
|
}
|