diff --git a/app/Http/Controllers/OpportunityManagementController.php b/app/Http/Controllers/OpportunityManagementController.php index 22f97b5b..4f54a4b1 100644 --- a/app/Http/Controllers/OpportunityManagementController.php +++ b/app/Http/Controllers/OpportunityManagementController.php @@ -8,6 +8,7 @@ use App\Models\Sector; use App\Services\InvestmentGeography; use App\Services\OpportunityManagement; use App\Services\OpportunityManagementInstallation; +use App\Services\OpportunityResources; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -69,7 +70,7 @@ class OpportunityManagementController extends Controller $values = $record ? ($record->management_draft ? json_decode($record->management_draft, true) : $record->only(OpportunityManagement::FIELDS)) : []; $classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null; - return Inertia::render('management/opportunity-draft', ['record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view'), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'categories' => OpportunityManagement::CATEGORIES, + return Inertia::render('management/opportunity-draft', ['resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view'), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'categories' => OpportunityManagement::CATEGORIES, 'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true), 'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]); } diff --git a/app/Http/Controllers/OpportunityResourceController.php b/app/Http/Controllers/OpportunityResourceController.php index e0ac1765..d6d4cf74 100644 --- a/app/Http/Controllers/OpportunityResourceController.php +++ b/app/Http/Controllers/OpportunityResourceController.php @@ -4,26 +4,31 @@ namespace App\Http\Controllers; use App\Models\Opportunity; use App\Services\InvestmentGeography; +use App\Services\OpportunityImageProcessor; use App\Services\OpportunityManagement; use App\Services\OpportunityResourceInstallation; use App\Services\OpportunityResources; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\URL; use Inertia\Inertia; +use Inertia\Response; use Symfony\Component\HttpFoundation\HeaderUtils; class OpportunityResourceController extends Controller { - public function installation(Request $request, OpportunityResourceInstallation $service) + public function installation(Request $request, OpportunityResourceInstallation $service): JsonResponse|Response { $preview = [...$service->preview(), 'actionUrl' => '/management/opportunity-resources-install']; return $request->expectsJson() ? response()->json($preview) : Inertia::render('management/opportunity-install', $preview); } - public function applyInstallation(Request $request, OpportunityResourceInstallation $service) + public function applyInstallation(Request $request, OpportunityResourceInstallation $service): RedirectResponse { $data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']); $service->apply($request->user(), $data['hash']); @@ -31,7 +36,7 @@ class OpportunityResourceController extends Controller return back()->with('success', 'Recursos instalados; nenhum ficheiro publicado ou oportunidade submetida.'); } - public function index(int $id, OpportunityResources $service) + public function index(int $id, OpportunityResources $service): Response { $opportunity = Opportunity::withTrashed()->findOrFail($id); abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.'); @@ -43,11 +48,19 @@ class OpportunityResourceController extends Controller $p = $location->payload; $location->territoryPath = app(InvestmentGeography::class)->path($p['locality_id'] ?? $p['admin_post_id'] ?? $p['district_id'] ?? $p['province_id'] ?? null, true); } + foreach ($resources['images'] as $image) { + $image->derivativePreviews = []; + foreach ($image->payload['derivatives'] ?? [] as $derived) { + if (DB::table('documents')->where('id', $derived['document_id'])->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists()) { + $image->derivativePreviews[] = [...$derived, 'url' => URL::temporarySignedRoute('opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])]; + } + } + } return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'assessment' => $this->assessment($resources)]); } - public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null) + public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null): JsonResponse { $input = $request->all(); if (is_string($input['payload'] ?? null)) { @@ -58,14 +71,37 @@ class OpportunityResourceController extends Controller return response()->json(['id' => $record->id, 'message' => 'Recurso guardado no servidor. Não publicado; validação final e scan são independentes.', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))], $resourceId ? 200 : 201); } - public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service) + public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service): JsonResponse { $service->lifecycle($request->user(), $id, $kind, $resourceId, $request->all()); return response()->json(['message' => 'Exclusão lógica/restauração registada; original e versões preservados.']); } - public function download(int $id, string $kind, string $resourceId, OpportunityResources $service) + public function prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse + { + $service->prepare($request->user(), $id, $resourceId, $request->all()); + + return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']); + } + + public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response + { + $o = Opportunity::findOrFail($id); + abort_if($o->status === 'archived', 404); + $image = DB::table('opportunity_images')->where('opportunity_id', $id)->where('id', $resourceId)->whereNull('deleted_at')->first(); + abort_unless($image !== null, 404); + $metadata = json_decode($image->payload, true); + abort_unless(in_array($documentId, array_column($metadata['derivatives'] ?? [], 'document_id'), true), 404); + $doc = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first(); + abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 423, 'Scan da derivada pendente.'); + $bytes = Crypt::decryptString(Storage::disk('local')->get($doc->storage_key)); + abort_unless(hash_equals(trim($doc->sha256), hash('sha256', $bytes)), 409, 'Integridade divergente.'); + + return response($bytes)->header('Content-Type', 'image/webp')->header('X-Content-Type-Options', 'nosniff')->header('Cache-Control', 'private,no-store'); + } + + public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response { abort_unless(in_array($kind, ['documents', 'images'], true), 404); $opportunity = Opportunity::findOrFail($id); @@ -81,7 +117,7 @@ class OpportunityResourceController extends Controller return response($bytes)->header('Content-Type', $record->media_type)->header('Content-Disposition', HeaderUtils::makeDisposition('attachment', $record->file_name, 'document'))->header('X-Content-Type-Options', 'nosniff')->header('Cache-Control', 'no-store'); } - /** @param array> $resources + /** @param array> $resources * @return list */ private function assessment(array $resources): array @@ -127,6 +163,9 @@ class OpportunityResourceController extends Controller if (count($districts) > 1 && ! array_filter($locations, fn ($r) => mb_strlen($r->payload['multi_district_rationale'] ?? '') >= 50)) { $issues[] = 'Multidistrito/interprovincial exige fundamento operacional de 50–1000 caracteres.'; } + if ($locations) { + $issues[] = 'Modo preparado: '.(count($locations) > 1 ? 'multiple' : 'single').'; âmbito derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : 'district')).'. Sem conversão de contexto em implantação.'; + } foreach (['images', 'documents'] as $kind) { foreach ($resources[$kind] as $r) { if ($r->deleted_at) { @@ -142,7 +181,21 @@ class OpportunityResourceController extends Controller } } if ($kind === 'images') { - $issues[] = 'Imagem '.$r->file->file_name.': revisão, derivadas sem EXIF e aprovação de divulgação ainda pendentes.'; + if (empty($r->payload['derivatives'])) { + $issues[] = 'Imagem '.$r->file->file_name.': derivadas sem EXIF ainda por preparar (GD/WebP e scan necessários).'; + } + $issues[] = 'Imagem '.$r->file->file_name.': revisão de dados pessoais/direitos e aprovação de divulgação ainda pendentes.'; + foreach (['title' => 5, 'caption' => 10, 'alt_text' => 10, 'author_source' => 2] as $field => $minimum) { + if (mb_strlen($r->payload[$field] ?? '') < $minimum) { + $issues[] = 'Imagem '.$r->file->file_name.': '.$field.' abaixo do mínimo para validação final.'; + } + } + if (($r->payload['role'] ?? null) === 'location' && empty($r->payload['location_id'])) { + $issues[] = 'Imagem de localização exige ligação a um local ativo da mesma oportunidade.'; + } + if (in_array($r->payload['role'] ?? null, ['asset', 'product', 'evidence'], true)) { + $issues[] = 'Imagem de ativo/produto/evidência: módulo e associação específica ainda pendentes.'; + } if (in_array($r->payload['rights_status'] ?? null, ['licensed', 'authorized'], true) && empty($r->payload['rights_document_id'])) { $issues[] = 'Imagem licenciada/autorizada exige comprovativo.'; } diff --git a/app/Services/OpportunityImageProcessor.php b/app/Services/OpportunityImageProcessor.php new file mode 100644 index 00000000..c132b99c --- /dev/null +++ b/app/Services/OpportunityImageProcessor.php @@ -0,0 +1,89 @@ + $input */ + public function prepare(User $actor, int $opportunityId, string $id, array $input): void + { + abort_unless($actor->can('manage-portal'), 403); + abort_unless(function_exists('imagecreatefromstring') && function_exists('imagewebp'), 423, 'GD/WebP não disponível neste processo. Original e metadados preservados; processamento pendente.'); + $data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:20|max:1000'])->validate(); + $stored = []; + $documents = []; + try { + DB::transaction(function () use ($actor, $opportunityId, $id, $data, &$stored, &$documents) { + $o = Opportunity::withTrashed()->whereKey($opportunityId)->lockForUpdate()->firstOrFail(); + abort_if($o->trashed() || $o->status === 'archived', 409, 'Restaure antes de processar.'); + abort_unless(hash_equals(app(OpportunityManagement::class)->etag($o), $data['etag']), 409, 'Conflito de versão; preserve os dados.'); + $image = DB::table('opportunity_images')->where('opportunity_id', $opportunityId)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first(); + abort_unless($image !== null, 404); + abort_unless((int) $image->version === (int) $data['version'], 409, 'Imagem alterada.'); + $doc = DB::table('documents')->where('id', $image->document_id)->first(); + abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 423, 'Scan do original pendente ou inseguro.'); + $disk = Storage::disk('local'); + $bytes = $doc->storage_encryption === 'laravel' ? Crypt::decryptString($disk->get($doc->storage_key)) : $disk->get($doc->storage_key); + abort_unless(hash_equals(trim($doc->sha256), hash('sha256', $bytes)), 409, 'Integridade divergente.'); + $size = @getimagesizefromstring($bytes); + abort_unless($size && $size[0] >= 400 && $size[1] >= 400 && $size[0] * $size[1] <= 40000000, 422, 'Dimensões inválidas ou imagem excessiva para processamento seguro.'); + $decoded = @imagecreatefromstring($bytes); + abort_unless($decoded !== false, 422, 'Não foi possível descodificar a imagem.'); + $derivatives = []; + try { + foreach (array_unique(array_map(fn ($width) => min($width, $size[0]), [320, 640, 1280])) as $width) { + $w = min($width, $size[0]); + $h = max(1, (int) round($size[1] * $w / $size[0])); + $canvas = imagecreatetruecolor($w, $h); + imagealphablending($canvas, false); + imagesavealpha($canvas, true); + imagecopyresampled($canvas, $decoded, 0, 0, 0, 0, $w, $h, $size[0], $size[1]); + ob_start(); + $encoded = imagewebp($canvas, null, 85); + $webp = ob_get_clean(); + imagedestroy($canvas); + abort_unless($encoded && is_string($webp), 500, 'Falha ao gerar WebP.'); + $documentId = (string) Str::uuid(); + $key = 'opportunity-resources/'.$documentId; + abort_unless($disk->put($key, Crypt::encryptString($webp)) === true, 500, 'Falha ao guardar derivada cifrada.'); + $stored[] = $key; + $documents[] = $documentId; + DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'opportunity-image-derivatives', 'file_name' => 'image-'.$width.'.webp', 'media_type' => 'image/webp', 'storage_key' => $key, 'storage_encryption' => 'laravel', 'sha256' => hash('sha256', $webp), 'status' => 'quarantined', 'data_classification' => 'restricted', 'size_bytes' => strlen($webp), 'uploaded_by' => $actor->id]); + $derivatives[] = ['document_id' => $documentId, 'width' => $w, 'height' => $h]; + } + } finally { + imagedestroy($decoded); + } + $payload = json_decode($image->payload, true); + $payload['derivatives'] = $derivatives; + $payload['derived_from_sha256'] = trim($doc->sha256); + $payload['processing_status'] = 'generated_without_original_metadata'; + DB::table('opportunity_images')->where('id', $id)->update(['payload' => json_encode($payload), 'version' => $image->version + 1, 'updated_at' => now()]); + DB::table('audit_events')->insert(['source_system' => 'opportunity-resources', 'entity_type' => 'images', 'action' => 'derivatives_prepared', 'actor_user_id' => $actor->id, 'data_classification' => 'restricted', 'before_data' => json_encode(['snapshot' => Crypt::encryptString(json_encode($image, JSON_THROW_ON_ERROR))]), 'after_data' => json_encode(['opportunity_id' => $opportunityId, 'resource_id' => $id, 'version' => $image->version + 1, 'reason' => Crypt::encryptString($data['reason']), 'snapshot' => Crypt::encryptString(json_encode(DB::table('opportunity_images')->where('id', $id)->first(), JSON_THROW_ON_ERROR))])]); + $o->management_version++; + $o->save(); + }); + } catch (\Throwable $error) { + foreach ($stored as $key) { + Storage::disk('local')->delete($key); + } + throw $error; + } + // Each new byte stream receives its own scan; no automatic clean result inherited from the original. + foreach ($documents as $documentId) { + try { + ScanDocumentForMalware::dispatch($documentId)->afterCommit(); + } catch (\Throwable) { + DB::table('documents')->where('id', $documentId)->update(['malware_scan_status' => 'failed', 'malware_failure_reason' => 'queue_dispatch_failed']); + } + } + } +} diff --git a/app/Services/OpportunityResourceInstallation.php b/app/Services/OpportunityResourceInstallation.php index b51dc37e..0e426c06 100644 --- a/app/Services/OpportunityResourceInstallation.php +++ b/app/Services/OpportunityResourceInstallation.php @@ -12,7 +12,7 @@ class OpportunityResourceInstallation public function fingerprint(): string { - $files = [__FILE__, app_path('Services/OpportunityResources.php'), app_path('Http/Controllers/OpportunityResourceController.php'), app_path('Jobs/ScanDocumentForMalware.php'), database_path('migrations/'.self::MIGRATION.'.php'), base_path('routes/web.php'), resource_path('js/pages/management/opportunity-resources.tsx'), resource_path('js/pages/management/opportunity-draft.tsx'), resource_path('js/pages/management/opportunity-install.tsx')]; + $files = [__FILE__, app_path('Services/OpportunityImageProcessor.php'), app_path('Services/OpportunityResources.php'), app_path('Http/Controllers/OpportunityResourceController.php'), app_path('Http/Controllers/OpportunityManagementController.php'), app_path('Jobs/ScanDocumentForMalware.php'), database_path('migrations/'.self::MIGRATION.'.php'), base_path('routes/web.php'), resource_path('js/pages/management/opportunity-resources.tsx'), resource_path('js/pages/management/opportunity-draft.tsx'), resource_path('js/pages/management/opportunity-install.tsx')]; return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR)); } @@ -21,7 +21,7 @@ class OpportunityResourceInstallation public function preview(): array { $path = storage_path('app/private/investmoz-v2/opportunity-resources-activation-proof.json'); - $proof = is_file($path) ? json_decode(file_get_contents($path), true) : []; + $proof = is_file($path) ? json_decode(file_get_contents($path) ?: '{}', true) : []; $identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); $code = $this->fingerprint(); $ready = ($proof['restore_verified'] ?? false) && ($proof['compatibility_verified'] ?? false) && ($proof['reversal_verified'] ?? false) && ($proof['qa_passed'] ?? false) && ($proof['code_hash'] ?? '') === $code && ($proof['target']['database'] ?? '') === $identity->database && (int) ($proof['target']['port'] ?? 0) === (int) $identity->port && strtotime($proof['verified_at'] ?? '') >= time() - 86400 && is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null); diff --git a/app/Services/OpportunityResources.php b/app/Services/OpportunityResources.php index c48d180b..dbe13214 100644 --- a/app/Services/OpportunityResources.php +++ b/app/Services/OpportunityResources.php @@ -32,7 +32,7 @@ class OpportunityResources return 'opportunity_'.($kind === 'locations' ? 'location_drafts' : $kind); } - /** @return list */ + /** @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'); @@ -44,11 +44,11 @@ class OpportunityResources } } - return $rows; + return array_values($rows); } /** @param array $input */ - public function save(User $actor, int $opportunityId, string $kind, array $input, ?UploadedFile $file = null, ?string $id = null): object + public function save(User $actor, int $opportunityId, string $kind, array $input, ?UploadedFile $file = null, ?string $id = null): \stdClass { abort_unless($actor->can('manage-portal'), 403); abort_unless($this->installed(), 503); @@ -56,7 +56,7 @@ class OpportunityResources $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' => 'required|array', + 'payload' => 'present|array', ])->validate(); $payload = $this->validatePayload($kind, $data['payload'], $opportunityId); $stored = null; @@ -73,6 +73,14 @@ class OpportunityResources 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.'); @@ -81,7 +89,7 @@ class OpportunityResources 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_intersect_key($payload, array_flip($keys)) == array_intersect_key($otherPayload, array_flip($keys)), 422, 'Esta localização, papel e unidade já existem.'); + 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.'); } } @@ -99,7 +107,7 @@ class OpportunityResources 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)), 500, 'Falha ao cifrar original.'); + 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]); } $values = ['payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'version' => $record ? $record->version + 1 : 1, 'updated_at' => now()]; @@ -115,9 +123,6 @@ class OpportunityResources $this->audit($actor, $kind, $record, $after, $data['reason'], 'saved'); $opportunity->management_version++; $opportunity->save(); - if ($documentId) { - ScanDocumentForMalware::dispatch($documentId)->afterCommit(); - } return $after; }); @@ -128,6 +133,14 @@ class OpportunityResources throw $error; } + if ($documentId) { + try { + ScanDocumentForMalware::dispatch($documentId)->afterCommit(); + } catch (\Throwable) { + DB::table('documents')->where('id', $documentId)->where('malware_scan_status', '!=', 'clean')->update(['status' => 'quarantined', 'malware_scan_status' => 'failed', 'malware_failure_reason' => 'queue_dispatch_failed', 'updated_at' => now()]); + } + } + return $result; } @@ -135,16 +148,18 @@ class OpportunityResources public function lifecycle(User $actor, int $opportunityId, string $kind, string $id, array $input): void { abort_unless($actor->can('manage-portal'), 403); + 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']); $table = $this->table($kind); $record = DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first(); - abort_unless($record, 404); + 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') { @@ -153,6 +168,11 @@ class OpportunityResources } 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') { @@ -160,6 +180,11 @@ class OpportunityResources 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++; @@ -224,7 +249,7 @@ class OpportunityResources 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()); + $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.']); } @@ -248,8 +273,8 @@ class OpportunityResources return [$key, $id]; } - private function audit(User $actor, string $kind, ?object $before, object $after, string $reason, string $action): void + 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))]), '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))])]); + 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))])]); } } diff --git a/database/migrations/2026_10_09_030000_extend_opportunity_resources.php b/database/migrations/2026_10_09_030000_extend_opportunity_resources.php index 86429b0f..ca9fde69 100644 --- a/database/migrations/2026_10_09_030000_extend_opportunity_resources.php +++ b/database/migrations/2026_10_09_030000_extend_opportunity_resources.php @@ -45,6 +45,13 @@ SQL); public function down(): void { + $identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); + if (! (app()->environment('testing') && str_ends_with($identity->database, '_test') && (int) $identity->port === 55441) && ! config('opportunity_resources.authenticated_installation')) { + throw new RuntimeException('Reversão exige QA isolada ou ação autenticada.'); + } + if (DB::table('documents')->whereNotNull('storage_encryption')->exists()) { + throw new RuntimeException('Preservar originais cifrados antes de reversão.'); + } foreach (['documents', 'images', 'location_drafts'] as $kind) { if (DB::table('opportunity_'.$kind)->exists()) { throw new RuntimeException('Preservar recursos antes de reversão.'); @@ -54,9 +61,6 @@ SQL); Schema::drop('opportunity_'.$kind); } DB::statement('DROP FUNCTION guard_opportunity_resource_delete()'); - if (DB::table('documents')->whereNotNull('storage_encryption')->exists()) { - throw new RuntimeException('Preservar originais cifrados antes de reversão.'); - } Schema::table('documents', function (Blueprint $t) { $t->dropColumn('storage_encryption'); }); diff --git a/docs/investmoz/v2/backlog-mestre.md b/docs/investmoz/v2/backlog-mestre.md index bb41212a..6ba4bd94 100644 --- a/docs/investmoz/v2/backlog-mestre.md +++ b/docs/investmoz/v2/backlog-mestre.md @@ -221,3 +221,11 @@ Todos mantêm estado editorial `published` e inclusão no filtro público do cat ### Gestão de Oportunidades — confirmação da instalação ativa Leitura em 09/10/2026 06:34:48 UTC: migration aplicada uma vez; evento `opportunity_management_installed` em 06:29:42 UTC, conta 13. Seis IDs classificados real, dados originais e relações iguais ao backup, 124 eventos históricos intactos; apenas um evento de instalação novo. [Confirmação detalhada](gestao-oportunidades-dashboard.md#confirmação-ativa--9-de-outubro-063448-utc). Nenhuma reinstalação ou escrita ativa pelo Codex. Imagens/documentos/multilocalização passam ao próximo incremento QA; workflow final e módulos especializados continuam pendentes. + +### V2 — imagens, documentos e multilocalização: incremento QA + +[Requisitos por secção, evidência e plano único](recursos-oportunidades.md). Originais em `documents`, territórios em `geo_locations`, metadados associados à mesma oportunidade; recuperação, versões/ETag, motivo/confirmação, exclusão lógica/restauro e retenção implementados. Localizações até 50, papéis/unidades/áreas/distribuição, fonte/data e GeoJSON; implantação/contexto/impacto/abrangência separados. Relações existentes não são reinterpretadas. Cifragem de novos originais/derivados, scan pelo job existente e URLs autenticadas temporárias. Derivadas GD testadas apenas em QA; PHP ativo sem GD carregado e ClamD real indisponível. + +**QA:** 80 testes passaram, 1 252 asserções, três casos reais de ClamD saltados; percurso desktop/móvel e nova sessão sem armazenamento local passaram. Restauro/reversão de 288 tabelas/cinco ficheiros; 14 conjuntos preservados após navegador. **Ativo:** gestão básica confirmada; seis IDs reais e 124 eventos anteriores preservados, dois eventos posteriores; recursos ainda não instalados, zero eventos próprios. Plano `/management/opportunity-resources-install`, válido até 10/10/2026 11:09:13 UTC, exclusivamente autenticado. + +**Lacunas mantidas:** publicação/revisão segregada, direitos/dados pessoais, editor cartográfico integral/limites oficiais em falta, catálogos pesquisáveis, evidências por campo, módulos especializados e critérios integrais da especificação. Cobertura do inventário, questões jurídicas, ClamD real, fontes institucionais específicas e primeiro pedido real continuam separados. Fontes oficiais já aceites não exigem segunda validação. diff --git a/docs/investmoz/v2/gestao-oportunidades-dashboard.md b/docs/investmoz/v2/gestao-oportunidades-dashboard.md index 429b675c..63839e80 100644 --- a/docs/investmoz/v2/gestao-oportunidades-dashboard.md +++ b/docs/investmoz/v2/gestao-oportunidades-dashboard.md @@ -1,6 +1,6 @@ # Gestão de Oportunidades integrada no Dashboard -Entrega de 9 de outubro de 2026. **Primeiro incremento implementado e validado em QA; não instalado na base ativa. A especificação integral não está concluída.** +Entrega de 9 de outubro de 2026. **Primeiro incremento instalado pelo utilizador e confirmado por leitura. O incremento de recursos está preparado em QA, ainda não aplicado. A especificação integral não está concluída.** ## Fonte e reconciliação @@ -12,7 +12,7 @@ Disponibilidade: consulta interna autorizada pelo pedido. Situação documental: O cadastro anterior usava `Opportunity`, setores, territórios e relações existentes, mas exigia título/setor/moeda para guardar e permitia publicação pelo editor genérico. O incremento reutiliza essas entidades, dashboard, gate `manage-portal`, `audit_events` e geografia. Não executa SQL documental: mantém IDs bigint existentes em vez dos UUID dos exemplos. Referências técnicas novas são geradas pelo servidor; título, setor e moeda desconhecidos permanecem nulos. -## Matriz de cobertura +## Matriz de cobertura da primeira entrega | Fonte / requisito | Estado | Implementação e lacuna | | --- | --- | --- | @@ -45,7 +45,7 @@ Após navegador: oportunidades originais, relações, favoritos, pedidos, recibo ## Plano único de ativação -Abrir com conta autorizada [a página de instalação](http://127.0.0.1:8000/management/opportunity-management-install), rever plano/backup/destino, assinalar confirmação e executar **Aplicar plano de gestão**. **Não executado pelo Codex.** Até instalar, lista/editor serve o cadastro anterior, pois o esquema novo está ausente. +Plano inicial na [página de instalação](http://127.0.0.1:8000/management/opportunity-management-install), **já executado pelo utilizador** em 09/10/2026 06:29:42 UTC. Não repetir. As provas abaixo registam a preparação histórica; o novo plano de recursos está no relatório ligado no final desta página. O Codex apenas confirmou a instalação por leitura. Migration única `2026_10_09_020000_extend_opportunity_management`: permitir nulos em título/setor/moeda para drafts; acrescentar versão, categoria, proposta, chave idempotente, primeira submissão e marca histórica de fecho; instalar guarda de eliminação física. Não altera valores de negócio, classificações ou relações existentes. A página confere hash do plano/código, identidade, integridade do backup/restauro e QA desta versão, validade de 24 h; exige autenticação/permissão e audita aplicação. @@ -60,3 +60,9 @@ Pendências separadas: cobertura do inventário, questões jurídicas específic Leitura transacional `READ ONLY` confirma a migration aplicada uma vez. Evento `opportunity_management_installed`, ID `08c433b9-ba6d-4270-9034-78ec5b69e2f9`, conta 13, em 06:29:42 UTC, hash `1ae520dcb5427e0dbfb3fcbfbb75b188acb949a54248a39fc38b71241ca54f26`. Nenhuma reinstalação executada. Os seis IDs continuam classificados real. Comparação do conteúdo original das oportunidades (excluindo apenas colunas acrescentadas pela migration) e dez conjuntos relacionados confirma hashes iguais ao backup: seis registos, 28 localizações de projetos, 13 relações cluster–projeto, quatro pedidos/recibos, uma mensagem e 124 eventos anteriores preservados. Zero novas relações oportunidade–projeto/cluster. Apenas o evento de instalação foi acrescentado. Evidência privada `opportunity-management-active-read-only.json`; leitor versionado `tools/opportunity-management-active-preview.php`. O estado atual da primeira entrega é **aplicado pelo utilizador e confirmado por leitura**; as lacunas funcionais mantêm-se. + +## Próximo incremento — recursos em QA + +[Imagens, documentos e multilocalização: matriz, QA, lacunas e plano](recursos-oportunidades.md). Guardar/recuperar recursos, originais cifrados, versões e exclusão lógica/restauro implementados; coleção territorial até 50 itens com hierarquia/papéis, áreas/distribuição e GeoJSON validado. Relações existentes conservadas; novas associações são preparação privada até workflow explícito. Processador GD gera WebP/miniaturas sem EXIF e scan independente; GD ativo e ClamD real mantêm dependências concretas. Não publicar automaticamente. + +Leitura ativa em 11:10:21 UTC confirmou novamente os seis IDs reais, dados/relações e 124 eventos anteriores intactos; dois eventos posteriores explicam diferença global do histórico. Novo esquema de recursos ausente, zero eventos deste incremento. QA: 80 testes passaram/1 252 asserções, três ClamD reais saltados; navegador desktop/móvel passou. Plano único `/management/opportunity-resources-install`, backup/restauro e reversão em cópia, validade até 10/10/2026 11:09:13 UTC. Não aplicado automaticamente. diff --git a/docs/investmoz/v2/recursos-oportunidades.md b/docs/investmoz/v2/recursos-oportunidades.md new file mode 100644 index 00000000..389bd363 --- /dev/null +++ b/docs/investmoz/v2/recursos-oportunidades.md @@ -0,0 +1,56 @@ +# Recursos das oportunidades — imagens, documentos e multilocalização + +Entrega de 9 de outubro de 2026, após a instalação confirmada da gestão básica. **Implementação e QA preparados; novo incremento não instalado na base ativa.** Mantém a especificação DOCX SHA-256 `9f173ed3024d2beddef7888b692d8b81fc5e4700249955f2bae9bdd2eb2be504`, as fontes oficiais aceites e as classificações reais dos IDs 1–6. Não certifica a especificação integral. + +## Reutilização e limites de publicação + +As oportunidades, IDs e relações existentes são mantidos. `documents` continua a guardar os originais e cada nova versão binária. `geo_locations` fornece os territórios e a hierarquia. As três coleções dedicadas são metadados da mesma oportunidade: `opportunity_documents`, `opportunity_images` e `opportunity_location_drafts`. A última é preparação da associação, não um segundo cadastro territorial; as relações `opportunity_locations` já aceites são apresentadas e conservadas, sem inferir papéis ou converter contexto em implantação. + +Recursos novos permanecem em preparação privada, inclusive quando o campo de visibilidade proposto for público. Alterar estes metadados de uma oportunidade já publicada não modifica automaticamente a ficha pública. A revisão/submissão/publicação de uma versão completa continua no incremento de workflow. Não há aprovação institucional inferida nem publicação automática. + +## Cobertura e requisitos ainda abertos + +| Fonte | Implementado e testado | Lacuna concreta | +| --- | --- | --- | +| §11 — localizações | Coleção até 50 itens; hierarquia província/distrito/posto/localidade; múltiplos papéis/unidades; localização principal; áreas/unidades/distribuição; fonte/data; endereço; fundamento multidistrito; implantação/contexto/impacto/abrangência separados. Locais sem coordenadas guardados e recuperados. | Grelha/cartografia interativa integral, unidades de catálogo pesquisáveis e ligação a documento/página por seleção ainda pendentes. Requisitos finais são pendências visíveis, não bloqueiam rascunho. | +| §11 — coerência territorial | Duplicação por hierarquia/papel/unidade rejeitada; principal duplicada rejeitada. Resumo confere exatamente uma principal, soma de distribuição 100% ±0,01, âmbito derivado, lacunas e fundamento multidistrito. | Compatibilidade espacial com limites oficiais ausentes mantém-se indeterminada; não reabre aceitação das fontes oficiais. | +| §11 — geometrias | GeoJSON Point, LineString, Polygon/MultiPolygon EPSG:4326; PostGIS verifica validade, vazio e limites de coordenadas. Consulta por metadados; coordenadas não são inventadas. | Sem limites correspondentes, não declara compatibilidade territorial. Área poligonal/requisitos finais completos e desenho no mapa pendentes. | +| §13 — documentos | PDF/DOCX/XLSX/PNG/JPEG, conteúdo/MIME e limites, checksum, original cifrado, categoria/emissor/edição/data/validade/classificação, secção/afirmação; substituição gera outro documento, preservando o anterior. | Evidências com proprietário/data de verificação e associação por campo, catálogos completos e workflow de aprovação documental pendentes. Fonte pendente não invalida um documento oficial já aceite. | +| §16 — imagens | JPEG/PNG/WebP, dimensões mínimas 400×400, conteúdo básico, deteção de animação/código, checksum e repetição na mesma oportunidade; título/legenda/alt/autor/data/direitos/comprovativo/visibilidade/ordem/capa e ligação a localização. | Descodificação integral ocorre no processador GD; revisão humana de dados pessoais/direitos e aprovação não foram simuladas. Aviso de duplicados entre oportunidades, catálogo pesquisável e edição por arrastar pendentes. | +| §16.1 — derivados | Processador GD explícito após scan do original; WebP responsivo sem metadados originais, sem deformar ou ampliar; originais preservados. Cada derivada recebe o próprio documento/hash/cifragem e scan independente. Pré-visualização autenticada com URL assinada por dez minutos. | GD não está carregado no PHP ativo; validação do processador feita apenas em processo QA com extensão carregada. ClamD real indisponível; nenhum ficheiro real declarado limpo. | +| §15.2/15.3 — ciclo/versionamento | Recuperação no servidor, versão por recurso e ETag da oportunidade, conflitos preservam preenchimento, permissões de gestão existentes, confirmação/motivo para exclusão lógica/restauro, retenção de binários, snapshots cifrados anterior/novo. Trigger proíbe eliminação física. Ligações a local/comprovativo impedem exclusão inadvertida do recurso usado. | Restauração não retoma automaticamente capa/principal/ordem; responsável deve reconciliar a seleção. Comparação visual detalhada dos snapshots e RBAC especializado permanecem no workflow futuro. | +| §20/21/24–28 | Guardar, preparar derivados e conferência distinguidos de validar/submeter/aprovar/publicar. Mesma origem e relações mantidas. | Submissão, revisão segregada, aprovação/publicação transacional com capa autorizada, módulos especializados e critérios integrais REQ/DT continuam pendentes. | + +A seleção de `asset`, `product` ou `evidence` numa imagem é rascunho; associação ao módulo correspondente ainda aparece pendente. Não declara a entidade ligada por semelhança. Metadados de consulta, situação documental e uso em regras/cálculos continuam independentes. Nada ativa um cálculo fiscal. + +## Segurança e indisponibilidade + +Originais e derivados novos ficam fora da pasta pública e cifrados com a chave da aplicação. A coluna nova `documents.storage_encryption` é nula para ficheiros antigos: esses originais e os seus scans não são alterados. O job existente `ScanDocumentForMalware` descifra apenas para ficheiro temporário privado, confere hash antes/depois e elimina o temporário em `finally`. Falha de fila conserva o original e regista scan falhado; nunca elimina um original já persistido para esconder a falha. + +Download exige autenticação/permissão, associação à mesma oportunidade/recurso ativo, scan limpo do hash atual e integridade dos bytes. Scan pendente/inconclusivo/falhado bloqueia apenas acesso ao binário/processamento que depende dele; guardar metadados e preparar locais continuam funcionais. URL de pré-visualização também exige assinatura válida e scan da derivada; visibilidade proposta não autoriza divulgação. + +`malware:health` confirmou **scanner_unavailable**, sem versão nem assinaturas disponíveis. Três testes reais continuam saltados. `QA-Test-Double` é usado exclusivamente em testes de bytes sintéticos e identificado como tal; não constitui scan de ficheiros reais. Não foi alterada a configuração do PHP ativo, nem ativado serviço externo. + +## Evidência QA + +**83 casos: 80 passaram, três saltados; 1 252 asserções.** Inclui regressão da gestão, origem, territórios/relações, zonas/anexos, portfólio e unificação. O processador foi ensaiado com `php -d extension=gd` apenas em QA: gera derivadas de imagem sintética, preserva original, retira metadados e exige scan próprio; URLs assinadas e permissões verificadas. TypeScript, PHPStan e build passaram. Um teste de renderização falhou durante build concorrente e passou na repetição isolada e na regressão com build concluído; não foi contornado. + +Navegador QA em `127.0.0.1:18096`: dashboard → nova oportunidade incompleta → recursos → duas localizações sem coordenadas com 60%/40% → PDF → PNG → erro visível de processamento, com dados preservados → nova sessão móvel sem armazenamento local → recuperação → exclusão lógica/restauro da imagem. Desktop e 390×844, sem overflow nem erros de execução. As capturas foram inspecionadas; rótulos corrigidos para português e hash legível no telemóvel. PHP servidor QA com GD apresentou falha de arranque; servidor sem GD passou, mostrando a dependência pendente. O processamento com GD foi comprovado no processo CLI isolado, não apresentado como sucesso no PHP ativo. + +Base `desk_restore_20261009_110903_test:55441`; backup/restauro de **288 tabelas/cinco ficheiros**, verificado em **11:09:13 UTC**. Migração/reversão antes de uso comparadas por hashes das 288 tabelas. Após navegador, comparação de 14 conjuntos confirma oportunidades, documentos originais, territórios, relações, favoritos, pedidos/recibos, mensagens, tarefas, localizações e histórico preservados. Não houve escrita na base ativa. + +Provas/capturas privadas em `storage/app/private/investmoz-v2/opportunity-resources-*`. Não publicar backups, configuração/chaves ou extração integral da fonte. + +## Plano único de ativação + +Abrir com conta autorizada [a instalação de recursos](http://127.0.0.1:8000/management/opportunity-resources-install), rever alterações e dependências, assinalar confirmação e executar **Aplicar plano de gestão**. A primeira instalação de gestão não é repetida. Migration única `2026_10_09_030000_extend_opportunity_resources`: três coleções de metadados, FK para oportunidades/documentos, coluna de cifragem opcional e proteção de eliminação física. Nenhum recurso criado automaticamente e nenhuma relação territorial ou classificação alterada. + +O plano exige prova de código `dcdfa8511b6b3554dd44ab236264822da8aaf2dd09eb8c66dcea78b0f2215e53`, backup/restauro, compatibilidade/reversão e QA correspondentes. Prova válida até **10/10/2026 11:09:13 UTC**; expiração ou alteração do código requer atualização. A ação autentica, confere hash/destino e audita `opportunity_resources_installed`. Não executada pelo Codex. + +Reversão direta apenas com coleções vazias e sem originais cifrados. Após uso, não apagar binários, relações ou versões: reconciliar/preservar antes de ação autenticada; restauro integral exige janela e consideração de escritas posteriores. A CLI da migration recusa a base ativa. + +## Estado ativo e pendências separadas + +Leitura em **09/10/2026 11:10:21 UTC**: gestão básica instalada uma vez pela conta 13; seis IDs reais e dados/relações preservados. Os 124 eventos anteriores foram comparados individualmente: **zero ausentes, zero modificados**. Dois eventos posteriores, incluindo a instalação, explicam a diferença do hash global do histórico; não é perda de histórico. + +Novo esquema de recursos ausente e zero eventos deste incremento na base ativa. ClamD real, fontes/limites concretos em falta, questões jurídicas específicas, diferença de cobertura do inventário e primeiro pedido real no Balcão mantêm estados independentes. Não revalidar proveniência oficial já aceite. diff --git a/resources/js/pages/management/opportunity-draft.tsx b/resources/js/pages/management/opportunity-draft.tsx index 64745752..8441a954 100644 --- a/resources/js/pages/management/opportunity-draft.tsx +++ b/resources/js/pages/management/opportunity-draft.tsx @@ -24,6 +24,7 @@ export default function OpportunityDraft({ categories, history, territoryPath, + resourcesInstalled = false, }: { record: Entry | null; values: Values; @@ -34,6 +35,7 @@ export default function OpportunityDraft({ sectors: { id: number; name: string }[]; categories: string[]; territoryPath: TerritoryOption[]; + resourcesInstalled?: boolean; history: { id: string; action: string; @@ -466,8 +468,16 @@ export default function OpportunityDraft({ )} {record && ( - - Imagens, documentos e localizações + + {resourcesInstalled + ? "Imagens, documentos e localizações" + : "Plano de ativação de imagens, documentos e localizações"} )} {record && ( diff --git a/resources/js/pages/management/opportunity-resources.tsx b/resources/js/pages/management/opportunity-resources.tsx index 3d5062e6..ae9d8fe3 100644 --- a/resources/js/pages/management/opportunity-resources.tsx +++ b/resources/js/pages/management/opportunity-resources.tsx @@ -13,6 +13,12 @@ type Resource = { deleted_at: string | null; payload: Payload; territoryPath?: TerritoryOption[]; + derivativePreviews?: { + document_id: string; + url: string; + width: number; + height: number; + }[]; file?: { file_name: string; malware_scan_status: string; @@ -58,6 +64,14 @@ const fields: Record = { ], }; const labels: Record = { + role: "Papel da localização", + imageRole: "Tipo de imagem", + territorial_purpose: "Natureza da relação territorial", + rights_status: "Direitos de utilização", + visibility: "Âmbito de consulta", + classification: "Classificação documental", + location_id: "Localização associada", + rights_document_id: "Comprovativo de direitos", title: "Título", address: "Referência física", operational_unit: "Unidade operacional", @@ -156,7 +170,7 @@ export default function OpportunityResources({ setConfirm(false); setErrors({}); } - async function execute(action: "save" | "delete" | "restore") { + async function execute(action: "save" | "delete" | "restore" | "prepare") { if (busy) return; setBusy(true); setErrors({}); @@ -187,7 +201,7 @@ export default function OpportunityResources({ form.set("action", action); form.set("confirm", confirm ? "1" : "0"); } - const url = `/management/opportunities/${opportunity.id}/resources/${kind}${editing ? `/${editing.id}` : ""}${action === "save" ? "" : "/lifecycle"}`; + const url = `/management/opportunities/${opportunity.id}/resources/${kind}${editing ? `/${editing.id}` : ""}${action === "save" ? "" : action === "prepare" ? "/prepare" : "/lifecycle"}`; const csrf = document.cookie .split("; ") @@ -316,12 +330,32 @@ export default function OpportunityResources({ : "rascunho"}

{r.file && ( -

+

Scan: {r.file.malware_scan_status} · motor:{" "} {r.file.malware_engine ?? "não executado"} · hash: {r.file.sha256}

)} + {kind === "images" && + !r.deleted_at && + !!r.derivativePreviews?.length && ( + `${p.url} ${p.width}w`) + .join(", ")} + alt={String( + r.payload.alt_text ?? + "Pré-visualização interna pendente de descrição", + )} + onError={() => + setMessage( + "Pré-visualização indisponível ou endereço expirado; recarregue. Dados preservados.", + ) + } + /> + )} + )}