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']]); }); } }