feat: add governed territorial registry and versioned APIs

This commit is contained in:
2026-09-28 20:48:35 +02:00
parent 439918d143
commit a2692704d9
11 changed files with 1850 additions and 0 deletions
@@ -0,0 +1,155 @@
<?php
namespace App\Http\Controllers;
use App\Services\TerritorialRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TerritorialRegistryController extends Controller
{
public function sources(Request $request): JsonResponse
{
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]);
return response()->json(DB::table('geo_sources')->orderBy('id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
}
public function drafts(Request $request): 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')
->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());
}
public function index(Request $request, TerritorialRegistry $registry, ?string $collection = null): JsonResponse
{
$data = $request->validate(['q' => ['nullable', 'string', 'max:150'], 'cursor' => ['nullable', 'string', 'max:512'],
'limit' => ['nullable', 'integer', 'between:1,100'], 'parent_id' => ['nullable', 'uuid'],
'country_id' => ['nullable', 'uuid'], 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'],
'include_geometry' => ['nullable', 'in:0,false'], 'bbox' => ['prohibited'], 'near' => ['prohibited'], 'radius' => ['prohibited']]);
$types = ['countries' => 'country', 'provinces' => 'province', 'districts' => 'district', 'admin-posts' => 'admin_post', 'localities' => 'locality'];
$query = $registry->published()->select(['g.id', 'g.name', 'g.code', 'g.location_type', 'g.parent_id', 'g.version_no', 'p.valid_from', 'p.valid_to']);
if (isset($types[$collection])) {
$query->where('g.location_type', $types[$collection]);
}
if (! empty($data['q'])) {
$term = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $data['q']).'%';
$query->where(fn ($q) => $q->where('g.name', 'ilike', $term)->orWhere('g.code', 'ilike', $term)
->orWhereExists(fn ($n) => $n->selectRaw('1')->from('geo_names')->whereColumn('geo_location_id', 'g.id')->where('name', 'ilike', $term)));
}
foreach (['parent_id', 'country_id', 'province_id', 'district_id', 'admin_post_id'] as $key) {
if (! empty($data[$key])) {
if ($key === 'parent_id') {
$query->where('g.parent_id', $data[$key]);
} else {
$type = ['country_id' => 'country', 'province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post'][$key];
$query->whereRaw('EXISTS (WITH RECURSIVE a AS (SELECT id,parent_id,location_type,1 AS depth FROM geo_locations WHERE id=g.parent_id UNION ALL SELECT l.id,l.parent_id,l.location_type,a.depth+1 FROM geo_locations l JOIN a ON l.id=a.parent_id WHERE a.depth<5) SELECT 1 FROM a WHERE id=? AND location_type=?)', [$data[$key], $type]);
}
}
}
return response()->json($query->orderBy('g.id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
}
public function show(Request $request, TerritorialRegistry $registry, string $type, string $id): JsonResponse
{
abort_unless(array_key_exists($type, TerritorialRegistry::PARENTS), 404);
$row = $registry->published()->where('g.id', $id)->where('g.location_type', $type)
->first(['g.id', 'g.name', 'g.code', 'g.parent_id', 'g.location_type', 'g.version_no', 'p.valid_from', 'p.valid_to', 'p.capital_territory_id']);
abort_unless($row !== null, 404);
// An unapproved capital must not leak through a published province.
if ($row->capital_territory_id && ! $registry->published()->where('g.id', $row->capital_territory_id)->exists()) {
$row->capital_territory_id = null;
}
return response()->json(['territory' => $row, 'names' => DB::table('geo_names')->where('geo_location_id', $id)->get(['language', 'name', 'official'])]);
}
public function save(Request $request, TerritorialRegistry $registry, ?string $id = null): JsonResponse
{
$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')]);
}
public function source(Request $request): JsonResponse
{
$data = $request->validate(['name' => ['required', 'string', 'max:255'], 'responsible_entity' => ['nullable', 'string', 'max:255'],
'reference' => ['required', 'string', 'min:10', 'max:2000'], 'license' => ['nullable', 'string', 'max:2000'],
'checksum' => ['required', 'regex:/^[0-9a-f]{64}$/'], 'source_version' => ['required', 'string', 'max:255'],
'collected_at' => ['required', 'date_format:Y-m-d', 'before_or_equal:today'], 'original_crs' => ['nullable', 'string', 'max:255']]);
$id = (string) Str::uuid();
DB::transaction(function () use ($data, $id, $request) {
DB::select('SELECT pg_advisory_xact_lock(280926, hashtext(?))', [$data['checksum'].':'.$data['source_version']]);
abort_if(DB::table('geo_sources')->where('checksum', $data['checksum'])->where('source_version', $data['source_version'])->exists(), 422, 'Fonte e versão já registadas.');
DB::table('geo_sources')->insert([...$data, 'id' => $id, 'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
});
return response()->json(['id' => $id, 'status' => 'pending'], 201);
}
public function transition(Request $request, TerritorialRegistry $registry, string $id): JsonResponse
{
$data = $request->validate(['action' => ['required', 'in:pending_review,approved,published,rejected,archived'],
'version_no' => ['required', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000']]);
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();
$profile = DB::table('geo_territory_profiles')->where('geo_location_id', $id)->lockForUpdate()->first();
abort_unless($location && $profile, 404);
abort_unless($location->version_no === $data['version_no'], 409);
$from = ['pending_review' => ['draft', 'rejected'], 'approved' => ['pending_review'], 'published' => ['approved'],
'rejected' => ['pending_review'], 'archived' => ['draft', 'pending_review', 'approved', 'published', 'rejected']];
abort_unless(in_array($profile->status, $from[$data['action']], true), 409);
$actor = $request->user()->id;
$changes = ['status' => $data['action'], 'updated_at' => now()];
if ($data['action'] === 'pending_review') {
abort_unless($profile->editor_id === $actor, 403);
}
if (in_array($data['action'], ['approved', 'rejected'], true)) {
abort_if($profile->editor_id === $actor, 403, 'Revisão independente obrigatória.');
$changes['reviewer_id'] = $actor;
}
if ($data['action'] === 'published') {
abort_if(in_array($actor, [$profile->editor_id, $profile->reviewer_id], true), 403, 'Publicação exige terceiro interveniente.');
abort_unless(DB::table('geo_sources')->where('id', $profile->source_id)->where('status', 'approved')->exists(), 422, 'Fonte não aprovada.');
abort_if($profile->valid_from > today()->toDateString() || ($profile->valid_to && $profile->valid_to < today()->toDateString()), 422, 'Vigência inválida.');
abort_if($location->parent_id && ! $registry->published()->where('g.id', $location->parent_id)->exists(), 422, 'Ascendente não publicado.');
$changes['publisher_id'] = $actor;
}
DB::table('geo_territory_profiles')->where('geo_location_id', $id)->update($changes);
$version = $location->version_no + 1;
DB::table('geo_locations')->where('id', $id)->update(['version_no' => $version, 'data_classification' => $data['action'] === 'published' ? 'public' : 'internal']);
$registry->snapshot($id, $version, $request->user(), $data['reason']);
return response()->json(['id' => $id, 'status' => $data['action'], 'version_no' => $version]);
});
}
public function sourceTransition(Request $request, string $id): JsonResponse
{
$data = $request->validate(['action' => ['required', 'in:approved,revoked'], 'approval_reference' => ['required', 'string', 'min:10', 'max:2000']]);
return DB::transaction(function () use ($request, $id, $data) {
$source = DB::table('geo_sources')->where('id', $id)->lockForUpdate()->first();
abort_unless($source !== null, 404);
if ($data['action'] === 'approved') {
abort_unless($source->status === 'pending', 409);
abort_if($source->editor_id === $request->user()->id, 403);
abort_unless($source->responsible_entity && $source->license, 422, 'Entidade e licença em falta.');
}
DB::table('geo_sources')->where('id', $id)->update(['status' => $data['action'], 'reviewer_id' => $request->user()->id,
'approval_reference' => $data['approval_reference'], 'updated_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'territorial-registry', 'entity_type' => 'geo_sources', 'entity_id' => $id,
'actor_user_id' => $request->user()->id, 'action' => $data['action'], 'after_data' => json_encode(['status' => $data['action']], JSON_THROW_ON_ERROR)]);
return response()->json(['id' => $id, 'status' => $data['action']]);
});
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class TerritorialRequest
{
/** @param Closure(Request): Response $next */
public function handle(Request $request, Closure $next): Response
{
$correlation = (string) Str::uuid();
try {
abort_unless(config('territorial.enabled'), 404);
if ($request->isMethodSafe()) {
$response = $next($request);
} 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',
} : 'editors';
abort_unless(in_array(strtolower($request->user()->email), config('territorial.'.$role, []), true), 403, 'Função editorial não atribuída.');
$key = $request->header('Idempotency-Key', '');
abort_unless(Str::isUuid($key), 422, 'Idempotency-Key UUID obrigatório.');
$scope = $request->method().':'.$request->path();
$actor = $request->user()->id;
$hash = hash('sha256', json_encode($request->all(), JSON_THROW_ON_ERROR));
$response = DB::transaction(function () use ($request, $next, $key, $scope, $actor, $hash) {
DB::select('SELECT pg_advisory_xact_lock(280926, hashtext(?))', [$actor.':'.$key.':'.$scope]);
$query = DB::table('geo_write_receipts')->where('actor_id', $actor)->where('idempotency_key', $key)->where('scope', $scope);
$existing = $query->first();
if ($existing) {
abort_unless(hash_equals($existing->request_hash, $hash), 409, 'Chave reutilizada com conteúdo diferente.');
return response(Crypt::decryptString($existing->response), $existing->http_status, ['Content-Type' => 'application/json']);
}
$result = $next($request);
if ($result->isSuccessful()) {
$body = $result->getContent();
if (! is_string($body)) {
throw new \RuntimeException('Expected JSON response body.');
}
DB::table('geo_write_receipts')->insert(['id' => (string) Str::uuid(), 'actor_id' => $actor, 'idempotency_key' => $key,
'scope' => $scope, 'request_hash' => $hash, 'response' => Crypt::encryptString($body), 'http_status' => $result->getStatusCode()]);
}
return $result;
});
}
} catch (ValidationException $exception) {
$response = response()->json(['type' => 'about:blank', 'title' => 'Dados inválidos', 'status' => 422, 'errors' => $exception->errors(), 'correlation_id' => $correlation], 422, ['Content-Type' => 'application/problem+json']);
} catch (HttpExceptionInterface $exception) {
$status = $exception->getStatusCode();
$response = response()->json(['type' => 'about:blank', 'title' => Response::$statusTexts[$status] ?? 'Request failed', 'status' => $status,
'detail' => $exception->getMessage(), 'correlation_id' => $correlation], $status, ['Content-Type' => 'application/problem+json']);
}
$response->headers->set('X-Correlation-ID', $correlation);
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
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'],
])->validate();
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;
});
}
public function snapshot(string $id, int $version, User $actor, string $reason): void
{
$snapshot = json_encode(['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,
'version_no' => $version, 'actor_id' => $actor->id, 'reason' => Crypt::encryptString($reason),
'snapshot' => Crypt::encryptString($snapshot), 'checksum' => hash('sha256', $snapshot)]);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'enabled' => (bool) env('INVESTMOZ_TERRITORIAL_ENABLED', false),
'editors' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TERRITORIAL_EDITORS', '')))))),
'reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TERRITORIAL_REVIEWERS', '')))))),
'publishers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_TERRITORIAL_PUBLISHERS', '')))))),
];
@@ -0,0 +1,101 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::statement('ALTER TABLE geo_locations DROP CONSTRAINT imz_geo_locations_check_03');
DB::statement("ALTER TABLE geo_locations ADD CONSTRAINT imz_geo_locations_check_03 CHECK(location_type IN ('country','province','district','admin_post','locality','municipality','site'))");
Schema::create('geo_sources', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->string('name');
$t->string('responsible_entity')->nullable();
$t->text('reference');
$t->text('license')->nullable();
$t->char('checksum', 64);
$t->string('source_version');
$t->date('collected_at');
$t->string('original_crs')->nullable();
$t->string('status')->default('pending');
$t->foreignId('editor_id')->constrained('users')->restrictOnDelete();
$t->foreignId('reviewer_id')->nullable()->constrained('users')->restrictOnDelete();
$t->text('approval_reference')->nullable();
$t->timestampsTz();
$t->unique(['checksum', 'source_version']);
});
DB::statement("ALTER TABLE geo_sources ADD CHECK(status IN ('pending','approved','revoked')), ADD CHECK(checksum ~ '^[0-9a-f]{64}$'), ADD CHECK(status<>'approved' OR (responsible_entity IS NOT NULL AND license IS NOT NULL))");
Schema::create('geo_territory_profiles', function (Blueprint $t) {
$t->foreignUuid('geo_location_id')->primary()->constrained('geo_locations')->restrictOnDelete();
$t->foreignUuid('source_id')->constrained('geo_sources')->restrictOnDelete();
$t->char('iso2', 2)->nullable()->unique();
$t->char('iso3', 3)->nullable()->unique();
$t->foreignUuid('capital_territory_id')->nullable()->constrained('geo_locations')->restrictOnDelete();
$t->date('valid_from');
$t->date('valid_to')->nullable();
$t->string('status')->default('draft');
$t->foreignId('editor_id')->constrained('users')->restrictOnDelete();
$t->foreignId('reviewer_id')->nullable()->constrained('users')->restrictOnDelete();
$t->foreignId('publisher_id')->nullable()->constrained('users')->restrictOnDelete();
$t->timestampsTz();
});
DB::statement("ALTER TABLE geo_territory_profiles ADD CHECK(valid_to IS NULL OR valid_to>=valid_from), ADD CHECK(status IN ('draft','pending_review','approved','published','rejected','archived')), ADD CHECK(iso2 IS NULL OR iso2 ~ '^[A-Z]{2}$'), ADD CHECK(iso3 IS NULL OR iso3 ~ '^[A-Z]{3}$'), ADD CHECK(status NOT IN ('approved','published') OR (reviewer_id IS NOT NULL AND reviewer_id<>editor_id)), ADD CHECK(status<>'published' OR (publisher_id IS NOT NULL AND publisher_id<>editor_id AND publisher_id<>reviewer_id))");
Schema::create('geo_names', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete();
$t->string('language', 10);
$t->string('name');
$t->string('normalized_name');
$t->boolean('official')->default(false);
$t->timestampsTz();
$t->unique(['geo_location_id', 'language', 'normalized_name']);
});
DB::statement('CREATE UNIQUE INDEX geo_names_official_language_uq ON geo_names(geo_location_id,language) WHERE official');
Schema::create('geo_territory_revisions', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete();
$t->unsignedInteger('version_no');
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$t->text('reason');
$t->text('snapshot');
$t->char('checksum', 64);
$t->timestampTz('created_at')->useCurrent();
$t->unique(['geo_location_id', 'version_no']);
});
DB::unprepared("CREATE OR REPLACE FUNCTION reject_territory_revision_change() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Territory revisions are immutable'; END $$; CREATE TRIGGER immutable_territory_revision BEFORE UPDATE OR DELETE ON geo_territory_revisions FOR EACH ROW EXECUTE FUNCTION reject_territory_revision_change()");
Schema::create('geo_write_receipts', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$t->uuid('idempotency_key');
$t->string('scope');
$t->char('request_hash', 64);
$t->text('response');
$t->integer('http_status');
$t->timestampTz('created_at')->useCurrent();
$t->unique(['actor_id', 'idempotency_key', 'scope']);
});
}
public function down(): void
{
$tables = ['geo_write_receipts', 'geo_territory_revisions', 'geo_names', 'geo_territory_profiles', 'geo_sources'];
foreach ($tables as $table) {
if (DB::table($table)->exists()) {
throw new RuntimeException('Territory evidence exists; destructive rollback refused.');
}
}
if (DB::table('geo_locations')->whereIn('location_type', ['admin_post', 'locality'])->exists()) {
throw new RuntimeException('New territory types exist; rollback refused.');
}
foreach ($tables as $table) {
Schema::drop($table);
}
DB::statement('DROP FUNCTION reject_territory_revision_change()');
DB::statement('ALTER TABLE geo_locations DROP CONSTRAINT imz_geo_locations_check_03');
DB::statement("ALTER TABLE geo_locations ADD CONSTRAINT imz_geo_locations_check_03 CHECK(location_type IN ('country','province','district','municipality','site'))");
}
};
+57
View File
@@ -0,0 +1,57 @@
# MVP territorial — implementação incremental
28/09/2026. Fonte funcional: novo documento «InvestMoz — Modelo de Dados Geográficos, Plano de Implementação do MVP e APIs Territoriais», recebido nesta sessão com autorização integral. Branch `feature/territorial-mvp`, preservando o Mercado de Negócios já implementado. Esta entrega é um subconjunto funcional; não é conclusão integral do MVP.
## Decisão de compatibilidade
Manter `geo_locations` como identidade única referenciada pelos módulos existentes. Não criar cinco cadastros independentes que dupliquem os UUID. O discriminador passa a admitir `admin_post` e `locality`, além dos tipos existentes. `geo_territory_profiles` acrescenta proveniência, vigência, estado, ISO e capital por referência. Nomes multilingues ficam em `geo_names`, com índice de um nome oficial por idioma/entidade. As cinco tabelas geo_country/geo_province/etc. do documento são representadas por tipos e perfis; views com esses nomes ainda não foram criadas.
O índice legado de código/tipo é global, mais restritivo que código de província por país. Não foi relaxado nem recodificado o legado sem mapeamento; a importação deverá distinguir código canónico e código oficial da fonte. Não se inventaram códigos oficiais ou coordenadas. O Excel conhecido tem 20.407 linhas após o cabeçalho, mas ainda não foi transformado numa hierarquia aprovada.
## Modelo e regras implementadas
Migration `2026_09_28_150000_territorial_registry.php` acrescenta cinco tabelas:
| Tabela | Finalidade e controlos |
| --- | --- |
| geo_sources | Fonte, entidade, referência, licença, checksum, versão, data, CRS original e estado; checksum/versão únicos; editor/revisor e evidência da decisão |
| geo_territory_profiles | FK única para geo_locations; fonte, ISO2/ISO3 opcionais únicos, capital por FK, vigência, estado, editor/revisor/publicador |
| geo_names | Nomes/idiomas por FK; nome normalizado e oficial; unicidade por entidade/idioma/nome e um oficial por idioma |
| geo_territory_revisions | Snapshot e motivo cifrados, actor, versão e checksum; UPDATE/DELETE recusados pelo trigger |
| geo_write_receipts | Actor, chave UUID, escopo, hash do pedido e resposta cifrada; unicidade e serialização contra duplicação concorrente |
Hierarquia validada no serviço: país sem pai, província→país, distrito→província, posto→distrito e localidade→posto. Tipo é imutável; pai apagado, salto de nível e código/nome duplicado sob o mesmo pai são rejeitados. Alterações são serializadas, com versão esperada, snapshot e invalidação de publicação. Excepções de localidades sem posto ainda não têm workflow; não se cria pai fictício.
Publicação exige fonte aprovada, vigência actual e pai publicado. Editor, revisor e publicador distintos; técnico com manage-portal não recebe função editorial automaticamente. A consulta percorre ascendentes e oculta toda a descendência se qualquer fonte/ascendente for revogado, arquivado, privado, expirado ou eliminado. Não depende de cache. Capital não publicada não é exposta por FK na resposta.
## APIs implementadas
- GET `/api/v1/geo/countries`, `/provinces`, `/districts`, `/admin-posts`, `/localities`, `/search`.
- GET `/api/v1/geo/territories/{type}/{id}`.
- POST `/api/v1/geo/sources` e `/sources/{id}/transitions`.
- GET `/api/v1/geo/sources` e `/drafts`: metadados e fila editorial para gestores com MFA; não são consultas públicas.
- POST `/api/v1/geo/territories`, PATCH `/territories/{id}`, POST `/territories/{id}/transitions`.
Listagens usam cursor e limite 1–100. Pesquisa por nome/código e nomes alternativos; filtros por pai e ascendentes. Caracteres de wildcard na pesquisa são escapados. Dados devolvidos por allowlist; revisões e motivos não entram na consulta publicada.
[OpenAPI do subconjunto](territorial-openapi.json), gerado por `tools/territorial-openapi.mjs`. Sessão Laravel e email verificado são o adaptador local; **OAuth2/OIDC ainda não implementado**. Escritas exigem CSRF/origin, gestor com MFA, papel configurado e `Idempotency-Key` UUID. Replay revalida permissões e recusa corpo diferente com a mesma chave. Correlation ID é gerado por pedido. Erros capturados no middleware territorial usam Problem Details; auth/rate-limit exteriores continuam no formato global da aplicação.
Flag `INVESTMOZ_TERRITORIAL_ENABLED` desactivada por defeito. Papéis: `INVESTMOZ_TERRITORIAL_EDITORS`, `INVESTMOZ_TERRITORIAL_REVIEWERS`, `INVESTMOZ_TERRITORIAL_PUBLISHERS`, todos sem nomeações por defeito. A nomeação técnica não prova mandato institucional. Aprovação de fonte exige entidade/licença preenchidas e referência de evidência, analisadas por pessoa diferente do editor; não equivale a verificação automática externa.
## Testes e limites
Testes focados iniciais: 5 testes / 40 verificações aprovadas. Incluem invisibilidade de rascunhos, hierarquia, duplicados, conflito de versão, filtros por ascendente, revogação, MFA, idempotência, revisão/publicação independente e rollback vazio seguido de recusa com dados. PHPStan e Pint aprovados. Fixtures exclusivamente sintéticas na base isolada, incluindo nomes e aprovação de teste.
Nenhuma geometria nova, transformação, importação real, fonte institucional ou nomeação de responsáveis foi inventada. As geometrias/PostGIS/GiST legadas são preservadas. Este incremento não implementa boundary versions, centróides aprovados, containment, resolve, near, bbox, percentagem de cobertura, territorial_assignment, árvore histórica, pipeline de importação ou UI administrativa. Parâmetros geométricos nas listagens são rejeitados, não ignorados como se tivessem sido aplicados.
Também faltam integração dos selectores nos módulos, migração dos textos legados, métricas de qualidade/p95, carga, acessibilidade/UAT, homologação do provedor OIDC e operações de limites/capitais/nomes pela API. Campos presentes no schema não representam jornadas implementadas.
## Gates e rollback
Execução local: backup `storage/app/private/zones-backup-20260928-184538/` com restauro isolado e 108 tabelas reconciliadas. Migration 150000 aplicada; base passou a 113 tabelas e 41 migrations. As 108 tabelas anteriores conservaram contagens, excepto o ledger de migrations (40→41). As cinco tabelas territoriais novas estão vazias; nenhum território/fonte/geometria real foi criado. Configuração anterior preservada privadamente; `INVESTMOZ_TERRITORIAL_ENABLED=true` apenas local. Papéis editoriais continuam sem nomeações.
Suite completa anterior às duas consultas administrativas: **162 testes, 1687 verificações aprovadas**, 33790 ms; PHPStan e Pint aprovados. Consultas administrativas verificadas depois nos testes focados. Não houve frontend novo neste incremento nem alegação de validação visual do MVP territorial.
G1: desenho adaptado documentado; fontes/licenças/códigos reais continuam por comprovar. G2: núcleo técnico e testes de reversão executados em ambiente isolado. G3: subconjunto de APIs testado; OAuth, contratos completos e UI pendentes. G4: **No-Go para produção ou conclusão integral do MVP**.
`down()` recusa evidências ou novos tipos territoriais existentes antes de qualquer DROP; não usa CASCADE nem DROP EXTENSION. Quando há dados, suspender a flag e corrigir progressivamente, preservando revisões. Nenhuma aprovação genérica substitui autorização de divulgação de um limite ou homologa a qualidade territorial do Excel.
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
<?php
use App\Http\Controllers\TerritorialRegistryController;
use App\Http\Middleware\PrivateInvestmentResponse;
use App\Http\Middleware\RequireZoneMfa;
use App\Http\Middleware\TerritorialRequest;
use Illuminate\Support\Facades\Route;
Route::prefix('api/v1/geo')->middleware(['auth', 'verified', PrivateInvestmentResponse::class, 'throttle:api', TerritorialRequest::class])->group(function () {
Route::get('/search', [TerritorialRegistryController::class, 'index']);
Route::get('/{collection}', [TerritorialRegistryController::class, 'index'])->whereIn('collection', ['countries', 'provinces', 'districts', 'admin-posts', 'localities']);
Route::get('/territories/{type}/{id}', [TerritorialRegistryController::class, 'show'])->whereUuid('id');
Route::middleware(['can:manage-portal', RequireZoneMfa::class])->group(function () {
Route::get('/sources', [TerritorialRegistryController::class, 'sources']);
Route::get('/drafts', [TerritorialRegistryController::class, 'drafts']);
Route::post('/sources', [TerritorialRegistryController::class, 'source']);
Route::post('/territories', [TerritorialRegistryController::class, 'save']);
Route::patch('/territories/{id}', [TerritorialRegistryController::class, 'save'])->whereUuid('id');
Route::post('/territories/{id}/transitions', [TerritorialRegistryController::class, 'transition'])->whereUuid('id');
Route::post('/sources/{id}/transitions', [TerritorialRegistryController::class, 'sourceTransition'])->whereUuid('id');
});
});
+1
View File
@@ -1,6 +1,7 @@
<?php
require __DIR__.'/existing-business.php';
require __DIR__.'/territorial-registry.php';
require __DIR__.'/investment-zones.php';
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class TerritorialRegistryTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config(['territorial.enabled' => true]);
}
private function create(string $url, array $data)
{
return $this->postJson($url, $data, ['Idempotency-Key' => (string) Str::uuid()]);
}
private function patchTerritory(string $url, array $data)
{
return $this->patchJson($url, $data, ['Idempotency-Key' => (string) Str::uuid()]);
}
public function test_writes_require_idempotency_and_replays_recheck_current_permissions(): void
{
$user = $this->editor();
$this->actingAs($user);
$source = $this->source();
$this->postJson('/api/v1/geo/territories', $this->territory($source))->assertUnprocessable()->assertHeader('Content-Type', 'application/problem+json');
$key = ['Idempotency-Key' => (string) Str::uuid()];
$first = $this->postJson('/api/v1/geo/territories', $this->territory($source), $key)->assertOk()->json('id');
$this->postJson('/api/v1/geo/territories', $this->territory($source), $key)->assertOk()->assertJsonPath('id', $first);
$this->postJson('/api/v1/geo/territories', [...$this->territory($source), 'name' => 'Changed'], $key)->assertConflict();
config(['portal.managers' => []]);
$this->postJson('/api/v1/geo/territories', $this->territory($source), $key)->assertForbidden();
$this->assertDatabaseCount('geo_locations', 1);
}
private function editor(): User
{
$user = User::factory()->create();
$user->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
config(['portal.managers' => [$user->email]]);
config(['territorial.editors' => [$user->email]]);
return $user;
}
private function source(): string
{
return $this->create('/api/v1/geo/sources', ['name' => 'Synthetic source', 'reference' => 'Synthetic reference only',
'checksum' => str_repeat('a', 64), 'source_version' => 'test', 'collected_at' => today()->toDateString()])->assertCreated()->json('id');
}
/** @return array<string,mixed> */
private function territory(string $source, string $type = 'country', ?string $parent = null): array
{
return ['name' => 'Synthetic '.$type, 'code' => 'TEST-'.$type, 'location_type' => $type, 'parent_id' => $parent,
'source_id' => $source, 'valid_from' => today()->toDateString(), 'reason' => 'Synthetic territorial record'];
}
public function test_drafts_are_private_and_hierarchy_duplicates_and_stale_versions_are_rejected(): void
{
$this->actingAs($this->editor());
$source = $this->source();
$country = $this->create('/api/v1/geo/territories', $this->territory($source))->assertOk()->json('id');
$this->getJson('/api/v1/geo/sources')->assertOk()->assertJsonPath('data.0.id', $source);
$this->getJson('/api/v1/geo/drafts')->assertOk()->assertJsonPath('data.0.id', $country);
$this->create('/api/v1/geo/territories', $this->territory($source))->assertUnprocessable();
$this->create('/api/v1/geo/territories', $this->territory($source, 'district', $country))->assertUnprocessable();
$province = $this->create('/api/v1/geo/territories', $this->territory($source, 'province', $country))->assertOk()->json('id');
$this->getJson('/api/v1/geo/provinces')->assertOk()->assertJsonCount(0, 'data');
$this->patchTerritory('/api/v1/geo/territories/'.$province, [...$this->territory($source, 'province', $country), 'version_no' => 1])->assertOk()->assertJsonPath('version_no', 2);
$this->patchTerritory('/api/v1/geo/territories/'.$province, [...$this->territory($source, 'province', $country), 'version_no' => 1])->assertConflict();
$this->assertDatabaseCount('geo_territory_revisions', 3);
}
public function test_revoking_ancestor_source_hides_descendants_and_filters_are_scoped(): void
{
$this->actingAs($this->editor());
$source = $this->source();
$country = $this->create('/api/v1/geo/territories', $this->territory($source))->json('id');
$province = $this->create('/api/v1/geo/territories', $this->territory($source, 'province', $country))->json('id');
// Synthetic published fixture only; no production approval is inferred from this test.
$reviewer = User::factory()->create();
$publisher = User::factory()->create();
DB::table('geo_sources')->where('id', $source)->update(['status' => 'approved', 'responsible_entity' => 'Synthetic entity', 'license' => 'Synthetic licence']);
DB::table('geo_territory_profiles')->update(['status' => 'published', 'reviewer_id' => $reviewer->id, 'publisher_id' => $publisher->id]);
DB::table('geo_locations')->update(['data_classification' => 'public']);
$this->getJson('/api/v1/geo/provinces?country_id='.$country.'&limit=1')->assertOk()->assertJsonPath('data.0.id', $province);
$this->getJson('/api/v1/geo/provinces?country_id='.Str::uuid())->assertJsonCount(0, 'data');
DB::table('geo_territory_profiles')->where('geo_location_id', $country)->update(['status' => 'archived']);
$this->getJson('/api/v1/geo/provinces')->assertJsonCount(0, 'data');
$this->getJson('/api/v1/geo/territories/province/'.$province)->assertNotFound();
}
public function test_authentication_mfa_and_empty_rollback(): void
{
$this->getJson('/api/v1/geo/countries')->assertUnauthorized();
$user = User::factory()->create();
config(['portal.managers' => [$user->email]]);
$this->actingAs($user)->create('/api/v1/geo/sources', [])->assertForbidden();
$migration = require database_path('migrations/2026_09_28_150000_territorial_registry.php');
$migration->down();
$migration->up();
$this->actingAs($this->editor());
$this->source();
$this->expectException(\RuntimeException::class);
$migration->down();
}
public function test_editor_reviewer_publisher_workflow_and_source_revocation(): void
{
$editor = $this->editor();
$reviewer = $this->editor();
$publisher = $this->editor();
config(['portal.managers' => [$editor->email, $reviewer->email, $publisher->email],
'territorial.editors' => [$editor->email], 'territorial.reviewers' => [$reviewer->email, $editor->email],
'territorial.publishers' => [$publisher->email, $editor->email]]);
$this->actingAs($editor);
$source = $this->create('/api/v1/geo/sources', ['name' => 'Synthetic complete source', 'reference' => 'Synthetic source reference',
'checksum' => str_repeat('b', 64), 'source_version' => 'test', 'collected_at' => today()->toDateString(),
'responsible_entity' => 'Synthetic entity', 'license' => 'Synthetic test permission'])->json('id');
$id = $this->create('/api/v1/geo/territories', $this->territory($source))->json('id');
$url = '/api/v1/geo/territories/'.$id.'/transitions';
$this->create($url, ['version_no' => 1, 'action' => 'pending_review', 'reason' => 'Synthetic review requested'])->assertOk();
$this->create($url, ['version_no' => 2, 'action' => 'approved', 'reason' => 'Synthetic self review attempt'])->assertForbidden();
$this->actingAs($reviewer);
$this->create('/api/v1/geo/sources/'.$source.'/transitions', ['action' => 'approved', 'approval_reference' => 'Synthetic source review evidence'])->assertOk();
$this->create($url, ['version_no' => 2, 'action' => 'approved', 'reason' => 'Synthetic independent review'])->assertOk();
$this->actingAs($editor)->create($url, ['version_no' => 3, 'action' => 'published', 'reason' => 'Synthetic self publication attempt'])->assertForbidden();
$this->actingAs($publisher)->create($url, ['version_no' => 3, 'action' => 'published', 'reason' => 'Synthetic independent publication'])->assertOk();
$this->getJson('/api/v1/geo/countries')->assertJsonPath('data.0.id', $id);
$this->create('/api/v1/geo/sources/'.$source.'/transitions', ['action' => 'revoked', 'approval_reference' => 'Synthetic source revoked'])->assertOk();
$this->getJson('/api/v1/geo/countries')->assertJsonCount(0, 'data');
}
}
+22
View File
@@ -0,0 +1,22 @@
import fs from 'node:fs';
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } };
const string = { type: 'string' };
const schema = properties => ({ type: 'object', properties });
const paths = {};
const operation = (summary, parameters = [], body = null) => ({ summary, security: [{ laravelSession: [] }], parameters: [...parameters, ...(body ? [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] : [])],
...(body ? { requestBody: { required: true, content: { 'application/json': { schema: body } } } } : {}),
responses: Object.fromEntries([200, ...(summary.startsWith('Create source') ? [201] : []), 401, 403, 404, 409, 422, 429].map(code => [code, { description: code < 300 ? 'JSON result with X-Correlation-ID; private, no-store' : 'Request rejected; validation and workflow errors use Problem Details within territorial middleware' }])) });
for (const collection of ['countries','provinces','districts','admin-posts','localities','search']) paths['/'+collection] = { get: operation('Read currently published territories: '+collection,
['q','cursor','parent_id','country_id','province_id','district_id','admin_post_id'].map(name => ({ name, in: 'query', schema: string })).concat([{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100 } }])) };
paths['/territories/{type}/{id}'] = { get: operation('Read published territory and translated names', [id, { name: 'type', in: 'path', required: true, schema: { enum: ['country','province','district','admin_post','locality'] } }]) };
const territory = schema({ name: string, code: string, location_type: { enum: ['country','province','district','admin_post','locality'] }, parent_id: { type: ['string','null'], format: 'uuid' }, source_id: { type: 'string', format: 'uuid' }, valid_from: { type: 'string', format: 'date' }, valid_to: { type: ['string','null'], format: 'date' }, reason: { type: 'string', minLength: 10, maxLength: 1000 } });
territory.required = ['name','code','location_type','source_id','valid_from','reason'];
paths['/territories'] = { post: operation('Create private territorial draft; designated editor with MFA', [], territory) };
paths['/territories/{id}'] = { patch: operation('Replace draft attributes and invalidate editorial approvals', [id], { ...territory, required: [...territory.required,'version_no'], properties: { ...territory.properties, version_no: { type: 'integer', minimum: 1 } } }) };
paths['/territories/{id}/transitions'] = { post: operation('Submit, review, publish, reject or archive; independent actors required', [id], { ...schema({ action: { enum: ['pending_review','approved','published','rejected','archived'] }, version_no: { type: 'integer', minimum: 1 }, reason: { type: 'string', minLength: 10 } }), required: ['action','version_no','reason'] }) };
paths['/sources'] = { post: operation('Create source in pending state', [], { ...schema({ name: string, responsible_entity: string, reference: string, license: string, checksum: { type: 'string', pattern: '^[0-9a-f]{64}$' }, source_version: string, collected_at: { type: 'string', format: 'date' }, original_crs: string }), required: ['name','reference','checksum','source_version','collected_at'] }) };
paths['/sources/{id}/transitions'] = { post: operation('Independent source approval or revocation with evidence reference', [id], { ...schema({ action: { enum: ['approved','revoked'] }, approval_reference: { type: 'string', minLength: 10 } }), required: ['action','approval_reference'] }) };
paths['/sources'].get = operation('Management-only source metadata and decision evidence, MFA required');
paths['/drafts'] = { get: operation('Management-only territorial editorial records, MFA required') };
fs.writeFileSync('docs/investmoz/gis/territorial-openapi.json', JSON.stringify({ openapi: '3.1.0', info: { title: 'InvestMoz territorial registry — implemented subset', version: '0.1.0', description: 'Local session adapter. OAuth/OIDC, geometric queries, assignments, imports, hierarchy endpoint and administrative UI remain unimplemented. Response schemas require expansion before external certification.' }, servers: [{ url: '/api/v1/geo' }], paths, components: { securitySchemes: { laravelSession: { type: 'apiKey', in: 'cookie', name: 'deployment_session_cookie', description: 'Actual name configured by Laravel; write requests also require Laravel CSRF/origin protection. This is not OAuth/OIDC.' } } } }, null, 2)+'\n');
console.log('Territorial OpenAPI: '+Object.keys(paths).length+' paths');