Files
Investidor-V2/app/Services/OpportunityResources.php
T

304 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class OpportunityResources
{
public const KINDS = ['documents', 'images', 'locations'];
// Existing §10.2 catalogue; shared with the compatibility editor, no inferred conversions.
public const AREA_UNITS = ['sqm', 'ha', 'sqkm'];
public function installed(): bool
{
return Schema::hasTable('opportunity_images');
}
public function table(string $kind): string
{
abort_unless(in_array($kind, self::KINDS, true), 404);
return 'opportunity_'.($kind === 'locations' ? 'location_drafts' : $kind);
}
/** @return list<\stdClass> */
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<string,mixed> $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<string,mixed> $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<string,mixed> $payload
* @return array<string,mixed>
*/
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|=)|<script\b/i', $bytes) || (str_contains($bytes, 'acTL') && $file->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))])]);
}
}