*/ public function listing(int $opportunityId, string $kind): array { $query = DB::table($this->table($kind))->where('opportunity_id', $opportunityId)->orderBy('created_at')->orderBy('id'); $rows = $query->get()->all(); foreach ($rows as $row) { $row->payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR); if ($kind !== 'locations') { $row->file = DB::table('documents')->where('id', $row->document_id)->first(['file_name', 'media_type', 'size_bytes', 'sha256', 'malware_scan_status', 'malware_failure_reason', 'malware_engine']); } } return array_values($rows); } /** @param array $input */ public function save(User $actor, int $opportunityId, string $kind, array $input, ?UploadedFile $file = null, ?string $id = null): \stdClass { app(PortalProfileAccess::class)->authorize($actor, 'edit', Opportunity::withTrashed()->findOrFail($opportunityId)); abort_unless($this->installed(), 503); $table = $this->table($kind); $data = Validator::make($input, [ 'etag' => 'required|string|size:64', 'version' => ($id ? 'required' : 'nullable').'|integer|min:1', 'creation_key' => ($id ? 'nullable' : 'required').'|uuid', 'reason' => 'required|string|min:20|max:1000', 'payload' => 'present|array', ])->validate(); $payload = $this->validatePayload($kind, $data['payload'], $opportunityId); if ($kind === 'documents' && $id && $file) { $linked = DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->first(); abort_if($linked && isset(json_decode($linked->payload, true)['library_source']), 422, 'Este suporte reutiliza um original da biblioteca; seleccionar outra versão pela biblioteca.'); } $stored = null; $documentId = null; $scanContext = null; if ($file) { [$stored, $documentId] = $this->store($kind, $file); } elseif (! $id && $kind !== 'locations') { throw ValidationException::withMessages(['file' => 'Selecione o ficheiro original.']); } try { $result = DB::transaction(function () use ($actor, $opportunityId, $kind, $table, $data, $payload, $file, $id, $stored, $documentId, &$scanContext) { $opportunity = $this->lock($opportunityId, $data['etag']); app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); $record = $id ? DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first() : null; abort_if($id && ! $record, 404); abort_if($record && ($record->deleted_at || (int) $record->version !== (int) $data['version']), 409, 'Recurso alterado ou eliminado; preserve o preenchimento e reveja a versão.'); $resourceId = $id ?? $data['creation_key']; if ($record && $kind === 'images' && ! $file) { $previous = json_decode($record->payload, true); foreach (['derivatives', 'derived_from_sha256', 'processing_status'] as $computed) { if (isset($previous[$computed])) { $payload[$computed] = $previous[$computed]; } } } abort_if(! $id && DB::table($table)->where('id', $resourceId)->exists(), 409, 'Esta criação já foi registada; recupere o recurso existente.'); $active = DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at'); abort_if(! $id && $active->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos atingido.'); if ($kind === 'locations') { $others = (clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get(); foreach ($others as $other) { $otherPayload = json_decode($other->payload, true); $keys = ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'role', 'operational_unit']; abort_if(array_map(fn ($key) => $payload[$key] ?? null, $keys) === array_map(fn ($key) => $otherPayload[$key] ?? null, $keys), 422, 'Esta localização, papel e unidade já existem.'); abort_if(($payload['is_primary'] ?? false) && ($otherPayload['is_primary'] ?? false), 422, 'Já existe localização principal; altere a anterior antes de escolher outra.'); } } if ($kind === 'images') { foreach ((clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get() as $other) { $p = json_decode($other->payload, true); abort_if(($payload['is_cover'] ?? false) && ($p['is_cover'] ?? false), 422, 'Já existe uma capa; retire essa seleção antes de escolher outra.'); abort_if(isset($payload['sort_order'], $p['sort_order']) && $payload['sort_order'] === $p['sort_order'], 422, 'A ordem já está ocupada.'); } } if ($file) { $hash = hash_file('sha256', Storage::disk('local')->path($stored)); $duplicate = DB::table($table.' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.opportunity_id', $opportunityId)->where('d.sha256', $hash)->when($id, fn ($q) => $q->where('r.id', '!=', $id))->exists(); if ($duplicate) { throw ValidationException::withMessages(['file' => 'Os mesmos bytes já estão ligados a esta oportunidade; recupere o recurso, sem duplicar.']); } $original = Storage::disk('local')->get($stored); abort_unless(Storage::disk('local')->put($stored, Crypt::encryptString($original)) === true, 500, 'Falha ao cifrar original.'); DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'opportunity-resources', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored, 'storage_encryption' => 'laravel', 'sha256' => $hash, 'status' => 'quarantined', 'data_classification' => 'restricted', 'size_bytes' => $file->getSize(), 'uploaded_by' => $actor->id]); } if ($record && $kind === 'documents' && isset(json_decode($record->payload, true)['library_source'])) { abort_if($file !== null, 422, 'Este suporte reutiliza um original da biblioteca; seleccionar outra versão pela biblioteca.'); $payload['library_source'] = json_decode($record->payload, true)['library_source']; } $values = ['payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'version' => $record ? $record->version + 1 : 1, 'updated_at' => now()]; if ($kind !== 'locations' && $documentId) { $values['document_id'] = $documentId; } if ($record) { DB::table($table)->where('id', $id)->update($values); } else { DB::table($table)->insert([...$values, 'id' => $resourceId, 'opportunity_id' => $opportunityId, 'created_by' => $actor->id, 'created_at' => now()]); } $after = DB::table($table)->where('id', $resourceId)->first(); $this->audit($actor, $kind, $record, $after, $data['reason'], 'saved'); $opportunity->management_version++; $opportunity->save(); if ($documentId !== null) { $scanContext = app(OpportunityTechnicalIncidents::class)->scanContext($actor, $opportunity); } return $after; }); } catch (\Throwable $error) { if ($stored) { app(OpportunityResourceFileCleanup::class)->afterFailure([$stored], $error); } throw $error; } if ($documentId) { app(OpportunityScanDispatch::class)->dispatch($documentId, $scanContext); } return $result; } /** @param array $input */ public function lifecycle(User $actor, int $opportunityId, string $kind, string $id, array $input): void { app(PortalProfileAccess::class)->authorize($actor, 'edit', Opportunity::withTrashed()->findOrFail($opportunityId)); abort_unless($this->installed(), 503); $data = Validator::make($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'action' => 'required|in:delete,restore', 'reason' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate(); DB::transaction(function () use ($actor, $opportunityId, $kind, $id, $data) { $opportunity = $this->lock($opportunityId, $data['etag']); app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); $table = $this->table($kind); $record = DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first(); abort_unless($record !== null, 404); abort_if((int) $record->version !== (int) $data['version'], 409, 'Versão alterada.'); abort_if(($data['action'] === 'delete') === ($record->deleted_at !== null), 409, 'Estado já alterado.'); $payload = json_decode($record->payload, true); if ($data['action'] === 'restore') { abort_if(DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos ativos atingido; reconcilie antes de restaurar.'); $this->validatePayload($kind, $payload, $opportunityId); // Restoration never silently reclaims a primary/cover/order occupied by another resource. if ($kind === 'images') { $payload['is_cover'] = false; unset($payload['sort_order']); } if ($kind === 'locations') { $payload['is_primary'] = false; $keys = ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'role', 'operational_unit']; foreach (DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->get() as $other) { $otherPayload = json_decode($other->payload, true); abort_if(array_map(fn ($key) => $payload[$key] ?? null, $keys) === array_map(fn ($key) => $otherPayload[$key] ?? null, $keys), 422, 'Outra localização ocupa esta combinação; reconcilie antes de restaurar.'); } } } if ($kind === 'locations' && $data['action'] === 'delete') { foreach (DB::table('opportunity_images')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->get() as $image) { abort_if((json_decode($image->payload, true)['location_id'] ?? null) === $id, 422, 'Uma imagem usa esta localização; corrija a ligação antes de excluir.'); } } if ($kind === 'documents' && $data['action'] === 'delete') { foreach (DB::table('opportunity_images')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->get() as $image) { abort_if((json_decode($image->payload, true)['rights_document_id'] ?? null) === $id, 422, 'Uma imagem depende deste comprovativo; corrija a ligação antes de excluir.'); } } DB::table($table)->where('id', $id)->update(['deleted_at' => $data['action'] === 'delete' ? now() : null, 'version' => $record->version + 1, 'payload' => json_encode($payload), 'updated_at' => now()]); $this->audit($actor, $kind, $record, DB::table($table)->where('id', $id)->first(), $data['reason'], $data['action']); $opportunity->management_version++; $opportunity->save(); }); } private function lock(int $id, string $etag): Opportunity { $record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail(); abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure a oportunidade antes de editar recursos.'); if (! hash_equals(app(OpportunityManagement::class)->etag($record), $etag)) { throw new HttpResponseException(response()->json(['message' => 'Conflito de versão.', 'errors' => ['etag' => ['Oportunidade alterada. Preserve os dados e recarregue antes de reconciliar.']]], 409)); } return $record; } /** @param array $payload * @return array */ private function validatePayload(string $kind, array $payload, int $opportunityId): array { $rules = ['title' => 'nullable|string|max:120', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'visibility' => 'nullable|in:internal,authenticated,public']; if ($kind === 'locations') { $rules += ['country_code' => ['nullable', app(CountryCatalogue::class)->rule()]]; $rules += ['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid', 'role' => 'nullable|in:headquarters,production,raw_material,storage,logistics,market,support,other', 'role_description' => 'nullable|string|max:1000', 'operational_unit' => 'nullable|string|max:120', 'is_primary' => 'nullable|boolean', 'address' => 'nullable|string|max:300', 'geometry' => 'nullable|array', 'area_value' => 'nullable|numeric|gt:0', 'area_unit' => ['nullable', Rule::in(self::AREA_UNITS)], 'allocation_percentage' => 'nullable|numeric|between:0,100', 'territorial_purpose' => 'nullable|in:implementation,context,impact,coverage', 'multi_district_rationale' => 'nullable|string|max:1000']; } elseif ($kind === 'images') { $rules += ['role' => 'nullable|in:cover,gallery,location,asset,product,plan,rendering,evidence,other', 'role_description' => 'nullable|string|max:1000', 'is_cover' => 'nullable|boolean', 'caption' => 'nullable|string|max:300', 'alt_text' => 'nullable|string|max:250', 'location_id' => ['nullable', 'uuid', Rule::exists('opportunity_location_drafts', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'captured_at' => 'nullable|date_format:Y-m-d|before_or_equal:today', 'author_source' => 'nullable|string|max:150', 'rights_status' => 'nullable|in:owned,licensed,authorized,public_domain', 'rights_document_id' => ['nullable', 'uuid', Rule::exists('opportunity_documents', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'rights_valid_until' => 'nullable|date_format:Y-m-d', 'sort_order' => 'nullable|integer|min:0']; } else { $rules += ['category' => 'nullable|string|max:100', 'issuer' => 'nullable|string|max:255', 'edition' => 'nullable|string|max:120', 'valid_until' => 'nullable|date_format:Y-m-d', 'classification' => 'nullable|in:public,internal,confidential,restricted', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000']; } $data = Validator::make($payload, $rules)->validate(); if ($kind === 'locations') { if (! empty($data['country_code']) && $data['country_code'] !== 'MZ' && array_filter(array_intersect_key($data, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id'])))) { throw ValidationException::withMessages(['country_code' => 'Os territórios desta hierarquia pertencem a Moçambique. Escolha Moçambique ou retire a seleção territorial para guardar uma localização noutro país.']); } app(InvestmentGeography::class)->selection($data, true); if (! empty($data['geometry'])) { $geometry = $data['geometry']; if (! in_array($geometry['type'] ?? '', ['Point', 'LineString', 'Polygon', 'MultiPolygon'], true) || isset($geometry['crs'])) { throw ValidationException::withMessages(['geometry' => 'Use GeoJSON Point/LineString/Polygon/MultiPolygon EPSG:4326.']); } try { $check = DB::selectOne('WITH g AS (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) AS geom) SELECT ST_IsValid(geom) AS valid, ST_IsEmpty(geom) AS empty, ST_XMin(Box3D(geom)) AS xmin,ST_XMax(Box3D(geom)) AS xmax,ST_YMin(Box3D(geom)) AS ymin,ST_YMax(Box3D(geom)) AS ymax FROM g', [json_encode($geometry)]); if (! $check->valid || $check->empty || $check->xmin < -180 || $check->xmax > 180 || $check->ymin < -90 || $check->ymax > 90) { throw new \RuntimeException; } } catch (\Throwable) { throw ValidationException::withMessages(['geometry' => 'Geometria inválida, vazia ou fora de EPSG:4326.']); } $data['spatial_compatibility'] = 'pending_official_boundary'; } } return $data; } /** @return array{string,string} */ private function store(string $kind, UploadedFile $file): array { $mimes = $kind === 'images' ? 'image/jpeg,image/png,image/webp' : 'application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/png,image/jpeg'; Validator::make(['file' => $file], ['file' => ['required', 'file', 'mimetypes:'.$mimes, 'max:'.($kind === 'images' ? 10240 : 20480)]])->validate(); if (str_starts_with((string) $file->getMimeType(), 'image/')) { $size = @getimagesize($file->getRealPath()); if (! $size || $size[0] < 400 || $size[1] < 400) { throw ValidationException::withMessages(['file' => 'Imagem inválida ou inferior a 400 × 400 píxeis.']); } $bytes = file_get_contents($file->getRealPath()) ?: ''; if (preg_match('/<\?(?:php|=)|getMimeType() === 'image/png') || (str_contains($bytes, 'ANIM') && $file->getMimeType() === 'image/webp')) { throw ValidationException::withMessages(['file' => 'Imagem animada ou com conteúdo executável não permitida.']); } } if (str_contains((string) $file->getMimeType(), 'openxmlformats')) { $zip = new \ZipArchive; if ($zip->open($file->getRealPath()) !== true) { throw ValidationException::withMessages(['file' => 'Pacote Office inválido.']); } $required = str_contains($file->getMimeType(), 'wordprocessingml') ? 'word/document.xml' : 'xl/workbook.xml'; $valid = $zip->locateName($required) !== false && $zip->locateName('[Content_Types].xml') !== false; $zip->close(); if (! $valid) { throw ValidationException::withMessages(['file' => 'Conteúdo Office não corresponde ao formato indicado.']); } } $id = (string) Str::uuid(); try { $key = $file->storeAs('opportunity-resources', $id, 'local'); abort_unless(is_string($key), 500, 'Falha ao guardar original privado.'); } catch (\Throwable $error) { app(OpportunityResourceFileCleanup::class)->afterFailure(['opportunity-resources/'.$id], $error); throw $error; } return [$key, $id]; } private function audit(User $actor, string $kind, ?\stdClass $before, \stdClass $after, string $reason, string $action): void { DB::table('audit_events')->insert(['source_system' => 'opportunity-resources', 'entity_type' => $kind, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'before_data' => json_encode(['snapshot' => Crypt::encryptString(json_encode($before, JSON_THROW_ON_ERROR))]), 'after_data' => json_encode(['opportunity_id' => $after->opportunity_id, 'resource_id' => $after->id, 'version' => $after->version, 'reason' => Crypt::encryptString($reason), 'snapshot' => Crypt::encryptString(json_encode($after, JSON_THROW_ON_ERROR))])]); } }