feat: add governed territorial registry and versioned APIs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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'))");
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/existing-business.php';
|
||||
require __DIR__.'/territorial-registry.php';
|
||||
|
||||
require __DIR__.'/investment-zones.php';
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user