From a2692704d9f620fc179caf39af38a19dc3f4a1af Mon Sep 17 00:00:00 2001 From: nala Date: Mon, 28 Sep 2026 20:48:35 +0200 Subject: [PATCH] feat: add governed territorial registry and versioned APIs --- .../TerritorialRegistryController.php | 155 +++ app/Http/Middleware/TerritorialRequest.php | 69 + app/Services/TerritorialRegistry.php | 79 ++ config/territorial.php | 8 + ...2026_09_28_150000_territorial_registry.php | 101 ++ docs/investmoz/gis/territorial-mvp.md | 57 + docs/investmoz/gis/territorial-openapi.json | 1191 +++++++++++++++++ routes/territorial-registry.php | 22 + routes/web.php | 1 + tests/Feature/TerritorialRegistryTest.php | 145 ++ tools/territorial-openapi.mjs | 22 + 11 files changed, 1850 insertions(+) create mode 100644 app/Http/Controllers/TerritorialRegistryController.php create mode 100644 app/Http/Middleware/TerritorialRequest.php create mode 100644 app/Services/TerritorialRegistry.php create mode 100644 config/territorial.php create mode 100644 database/migrations/2026_09_28_150000_territorial_registry.php create mode 100644 docs/investmoz/gis/territorial-mvp.md create mode 100644 docs/investmoz/gis/territorial-openapi.json create mode 100644 routes/territorial-registry.php create mode 100644 tests/Feature/TerritorialRegistryTest.php create mode 100644 tools/territorial-openapi.mjs diff --git a/app/Http/Controllers/TerritorialRegistryController.php b/app/Http/Controllers/TerritorialRegistryController.php new file mode 100644 index 00000000..7524d98c --- /dev/null +++ b/app/Http/Controllers/TerritorialRegistryController.php @@ -0,0 +1,155 @@ +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']]); + }); + } +} diff --git a/app/Http/Middleware/TerritorialRequest.php b/app/Http/Middleware/TerritorialRequest.php new file mode 100644 index 00000000..a44479f9 --- /dev/null +++ b/app/Http/Middleware/TerritorialRequest.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/app/Services/TerritorialRegistry.php b/app/Services/TerritorialRegistry.php new file mode 100644 index 00000000..a3f02337 --- /dev/null +++ b/app/Services/TerritorialRegistry.php @@ -0,0 +1,79 @@ + 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'], + ])->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)]); + } +} diff --git a/config/territorial.php b/config/territorial.php new file mode 100644 index 00000000..20894d3c --- /dev/null +++ b/config/territorial.php @@ -0,0 +1,8 @@ + (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', '')))))), +]; diff --git a/database/migrations/2026_09_28_150000_territorial_registry.php b/database/migrations/2026_09_28_150000_territorial_registry.php new file mode 100644 index 00000000..39935944 --- /dev/null +++ b/database/migrations/2026_09_28_150000_territorial_registry.php @@ -0,0 +1,101 @@ +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'))"); + } +}; diff --git a/docs/investmoz/gis/territorial-mvp.md b/docs/investmoz/gis/territorial-mvp.md new file mode 100644 index 00000000..33e29c69 --- /dev/null +++ b/docs/investmoz/gis/territorial-mvp.md @@ -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. diff --git a/docs/investmoz/gis/territorial-openapi.json b/docs/investmoz/gis/territorial-openapi.json new file mode 100644 index 00000000..6f8ac08b --- /dev/null +++ b/docs/investmoz/gis/territorial-openapi.json @@ -0,0 +1,1191 @@ +{ + "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": { + "/countries": { + "get": { + "summary": "Read currently published territories: countries", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "parent_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "country_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "admin_post_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/provinces": { + "get": { + "summary": "Read currently published territories: provinces", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "parent_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "country_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "admin_post_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/districts": { + "get": { + "summary": "Read currently published territories: districts", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "parent_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "country_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "admin_post_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/admin-posts": { + "get": { + "summary": "Read currently published territories: admin-posts", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "parent_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "country_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "admin_post_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/localities": { + "get": { + "summary": "Read currently published territories: localities", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "parent_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "country_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "admin_post_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/search": { + "get": { + "summary": "Read currently published territories: search", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "parent_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "country_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "admin_post_id", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/territories/{type}/{id}": { + "get": { + "summary": "Read published territory and translated names", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "enum": [ + "country", + "province", + "district", + "admin_post", + "locality" + ] + } + } + ], + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/territories": { + "post": { + "summary": "Create private territorial draft; designated editor with MFA", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "code": { + "type": "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 + } + }, + "required": [ + "name", + "code", + "location_type", + "source_id", + "valid_from", + "reason" + ] + } + } + } + }, + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/territories/{id}": { + "patch": { + "summary": "Replace draft attributes and invalidate editorial approvals", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "code": { + "type": "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 + }, + "version_no": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "name", + "code", + "location_type", + "source_id", + "valid_from", + "reason", + "version_no" + ] + } + } + } + }, + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/territories/{id}/transitions": { + "post": { + "summary": "Submit, review, publish, reject or archive; independent actors required", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "enum": [ + "pending_review", + "approved", + "published", + "rejected", + "archived" + ] + }, + "version_no": { + "type": "integer", + "minimum": 1 + }, + "reason": { + "type": "string", + "minLength": 10 + } + }, + "required": [ + "action", + "version_no", + "reason" + ] + } + } + } + }, + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/sources": { + "post": { + "summary": "Create source in pending state", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "responsible_entity": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "license": { + "type": "string" + }, + "checksum": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "source_version": { + "type": "string" + }, + "collected_at": { + "type": "string", + "format": "date" + }, + "original_crs": { + "type": "string" + } + }, + "required": [ + "name", + "reference", + "checksum", + "source_version", + "collected_at" + ] + } + } + } + }, + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "201": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + }, + "get": { + "summary": "Management-only source metadata and decision evidence, MFA required", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [], + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/sources/{id}/transitions": { + "post": { + "summary": "Independent source approval or revocation with evidence reference", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "enum": [ + "approved", + "revoked" + ] + }, + "approval_reference": { + "type": "string", + "minLength": 10 + } + }, + "required": [ + "action", + "approval_reference" + ] + } + } + } + }, + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + }, + "/drafts": { + "get": { + "summary": "Management-only territorial editorial records, MFA required", + "security": [ + { + "laravelSession": [] + } + ], + "parameters": [], + "responses": { + "200": { + "description": "JSON result with X-Correlation-ID; private, no-store" + }, + "401": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "403": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "404": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "409": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "422": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + }, + "429": { + "description": "Request rejected; validation and workflow errors use Problem Details within territorial middleware" + } + } + } + } + }, + "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." + } + } + } +} diff --git a/routes/territorial-registry.php b/routes/territorial-registry.php new file mode 100644 index 00000000..416230d0 --- /dev/null +++ b/routes/territorial-registry.php @@ -0,0 +1,22 @@ +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'); + }); +}); diff --git a/routes/web.php b/routes/web.php index a59bbf55..5d81e122 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ 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 */ + 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'); + } +} diff --git a/tools/territorial-openapi.mjs b/tools/territorial-openapi.mjs new file mode 100644 index 00000000..f07845be --- /dev/null +++ b/tools/territorial-openapi.mjs @@ -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');