feat: verify active opportunity management and prepare versioned resources

This commit is contained in:
2026-10-09 13:16:41 +02:00
parent 60784a2ed2
commit 9d6176f55b
16 changed files with 419 additions and 43 deletions
@@ -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']) : []]);
}
@@ -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<string,list<object>> $resources
/** @param array<string,list<\stdClass>> $resources
* @return list<string>
*/
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.';
}
@@ -0,0 +1,89 @@
<?php
namespace App\Services;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class OpportunityImageProcessor
{
/** @param array<string,mixed> $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']);
}
}
}
}
@@ -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);
+38 -13
View File
@@ -32,7 +32,7 @@ class OpportunityResources
return 'opportunity_'.($kind === 'locations' ? 'location_drafts' : $kind);
}
/** @return list<object> */
/** @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<string,mixed> $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|=)|<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.']);
}
@@ -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))])]);
}
}
@@ -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');
});
+8
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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({
)}
</form>
{record && (
<Link href={`/management/opportunities/${record.id}/resources`}>
Imagens, documentos e localizações
<Link
href={
resourcesInstalled
? `/management/opportunities/${record.id}/resources`
: "/management/opportunity-resources-install"
}
>
{resourcesInstalled
? "Imagens, documentos e localizações"
: "Plano de ativação de imagens, documentos e localizações"}
</Link>
)}
{record && (
@@ -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<Kind, string[]> = {
],
};
const labels: Record<string, string> = {
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"}
</p>
{r.file && (
<p>
<p className="break-all">
Scan: {r.file.malware_scan_status} · motor:{" "}
{r.file.malware_engine ?? "não executado"} ·
hash: {r.file.sha256}
</p>
)}
{kind === "images" &&
!r.deleted_at &&
!!r.derivativePreviews?.length && (
<img
className="max-w-full"
src={r.derivativePreviews[0].url}
srcSet={r.derivativePreviews
.map((p) => `${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.",
)
}
/>
)}
<button
className={button}
onClick={() => select(kind, r)}
@@ -389,6 +423,7 @@ export default function OpportunityResources({
error={errors.file}
>
<input
key={key}
aria-label="Original / substituir por nova versão"
type="file"
accept={
@@ -437,8 +472,9 @@ export default function OpportunityResources({
).map((name) => (
<Field
key={name}
label={name}
label={labels[name] ?? name}
error={
errors[name === "imageRole" ? "role" : name] ??
errors[
`payload.${name === "imageRole" ? "role" : name}`
]
@@ -446,7 +482,7 @@ export default function OpportunityResources({
>
<select
className={control}
aria-label={name}
aria-label={labels[name] ?? name}
value={String(
payload[name === "imageRole" ? "role" : name] ??
"",
@@ -488,8 +524,10 @@ export default function OpportunityResources({
(name) => (
<Field
key={name}
label={name}
error={errors[`payload.${name}`]}
label={labels[name] ?? name}
error={
errors[name] ?? errors[`payload.${name}`]
}
>
<select
className={control}
@@ -521,6 +559,7 @@ export default function OpportunityResources({
{kind !== "documents" && (
<label>
<input
key={key}
type="checkbox"
checked={
!!payload[
@@ -567,6 +606,16 @@ export default function OpportunityResources({
</label>
)}
<div className="flex flex-wrap gap-3">
{kind === "images" && editing && !editing.deleted_at && (
<button
type="button"
className={button}
disabled={busy || unavailable}
onClick={() => void execute("prepare")}
>
Preparar WebP e miniaturas
</button>
)}
<button
className={button}
disabled={busy || unavailable || !!editing?.deleted_at}
+2
View File
@@ -111,6 +111,8 @@ Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management
Route::get('opportunity-resources-install', [OpportunityResourceController::class, 'installation']);
Route::post('opportunity-resources-install', [OpportunityResourceController::class, 'applyInstallation']);
Route::get('opportunities/{id}/resources', [OpportunityResourceController::class, 'index'])->whereNumber('id');
Route::post('opportunities/{id}/resources/images/{resourceId}/prepare', [OpportunityResourceController::class, 'prepareImage'])->whereNumber('id');
Route::get('opportunities/{id}/resources/images/{resourceId}/preview/{documentId}', [OpportunityResourceController::class, 'previewImage'])->whereNumber('id')->middleware('signed')->name('opportunity-image-preview');
Route::post('opportunities/{id}/resources/{kind}', [OpportunityResourceController::class, 'save'])->whereNumber('id');
Route::post('opportunities/{id}/resources/{kind}/{resourceId}', [OpportunityResourceController::class, 'save'])->whereNumber('id');
Route::post('opportunities/{id}/resources/{kind}/{resourceId}/lifecycle', [OpportunityResourceController::class, 'lifecycle'])->whereNumber('id');
+35 -2
View File
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Tests\Support\CleanScannerFixture;
use Tests\TestCase;
@@ -75,7 +76,7 @@ class OpportunityResourcesTest extends TestCase
[$user,$o] = $this->setupDraft();
$province = (string) Str::uuid();
$district = (string) Str::uuid();
DB::table('geo_locations')->insert([['id' => $province, 'name' => 'QA province', 'location_type' => 'province', 'parent_id'=>null, 'data_classification' => 'public'], ['id' => $district, 'name' => 'QA district', 'location_type' => 'district', 'parent_id' => $province, 'data_classification' => 'public']]);
DB::table('geo_locations')->insert([['id' => $province, 'code' => $province, 'name' => 'QA province', 'location_type' => 'province', 'parent_id' => null, 'data_classification' => 'public'], ['id' => $district, 'code' => $district, 'name' => 'QA district', 'location_type' => 'district', 'parent_id' => $province, 'data_classification' => 'public']]);
$p = ['title' => 'QA production', 'province_id' => $province, 'district_id' => $district, 'role' => 'production', 'is_primary' => true, 'allocation_percentage' => 60, 'territorial_purpose' => 'implementation'];
$id = $this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o, $p))->assertCreated()->json('id');
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), $p))->assertUnprocessable();
@@ -93,7 +94,39 @@ class OpportunityResourcesTest extends TestCase
[$user,$o] = $this->setupDraft();
$this->post('/management/opportunities/'.$o->id.'/resources/images', [...$this->payload($o, ['title' => 'Bad image']), 'file' => UploadedFile::fake()->createWithContent('bad.png', 'invalid')], ['Accept' => 'application/json'])->assertUnprocessable();
$this->postJson('/management/opportunities/'.$o->id.'/resources/images', $this->payload($o, ['title' => 'QA image', 'location_id' => (string) Str::uuid()]))->assertUnprocessable()->assertJsonValidationErrors('location_id');
$this->assertSame(0,DB::table('opportunity_images')->count());
$this->assertSame(0, DB::table('opportunity_images')->count());
$this->actingAs(User::factory()->create())->get('/management/opportunity-resources-install')->assertForbidden();
}
public function test_image_processing_is_explicit_and_every_derivative_requires_its_own_scan(): void
{
[$user,$o] = $this->setupDraft();
$chunk = fn ($name, $bytes) => pack('N', strlen($bytes)).$name.$bytes.pack('N', crc32($name.$bytes));
$png = "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', 500, 500, 8, 2, 0, 0, 0)).$chunk('IDAT', gzcompress(str_repeat("\0", 500 * 1501))).$chunk('IEND', '');
$result = $this->post('/management/opportunities/'.$o->id.'/resources/images', [...$this->payload($o, ['title' => 'Synthetic QA image', 'alt_text' => 'Synthetic white square for QA', 'caption' => 'Synthetic image not used in active data', 'rights_status' => 'owned', 'is_cover' => true]), 'file' => UploadedFile::fake()->createWithContent('qa.png', $png)], ['Accept' => 'application/json'])->assertCreated()->json();
$image = DB::table('opportunity_images')->where('id', $result['id'])->first();
(new ScanDocumentForMalware($image->document_id))->handle(new CleanScannerFixture);
$url = '/management/opportunities/'.$o->id.'/resources/images/'.$image->id.'/prepare';
$input = ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 1, 'reason' => 'Processamento de imagem sintética em QA'];
if (! function_exists('imagecreatefromstring')) {
$this->postJson($url, $input)->assertStatus(423);
$this->assertDatabaseHas('opportunity_images', ['id' => $image->id, 'version' => 1]);
return;
}
$this->postJson($url, $input)->assertOk();
$payload = json_decode(DB::table('opportunity_images')->where('id', $image->id)->value('payload'), true);
$this->assertCount(2, $payload['derivatives']);
foreach ($payload['derivatives'] as $derivative) {
$document = DB::table('documents')->where('id', $derivative['document_id'])->first();
$this->assertSame('pending', $document->malware_scan_status);
$webp = Crypt::decryptString(Storage::disk('local')->get($document->storage_key));
$this->assertStringNotContainsString('EXIF', $webp);
$this->assertSame('image/webp', getimagesizefromstring($webp)['mime']);
(new ScanDocumentForMalware($document->id))->handle(new CleanScannerFixture);
}
$preview = URL::temporarySignedRoute('opportunity-image-preview', now()->addMinutes(10), ['id' => $o->id, 'resourceId' => $image->id, 'documentId' => $payload['derivatives'][0]['document_id']]);
$this->get($preview)->assertOk()->assertHeader('Content-Type', 'image/webp');
$this->actingAs(User::factory()->create())->get($preview)->assertForbidden();
}
}
@@ -7,11 +7,34 @@ $pdo->exec('BEGIN TRANSACTION READ ONLY');
$identity = $pdo->query("SELECT current_database() AS database,inet_server_port() AS port,current_setting('transaction_read_only') AS read_only")->fetch(PDO::FETCH_ASSOC);
$installed = (bool) $pdo->query("SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='opportunities' AND column_name='management_version')")->fetchColumn();
$migrationApplied = (int) $pdo->query("SELECT count(*) FROM migrations WHERE migration='2026_10_09_020000_extend_opportunity_management'")->fetchColumn();
$resourceSchemaInstalled = (bool) $pdo->query("SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='opportunity_images')")->fetchColumn();
$resourceAuditCount = (int) $pdo->query("SELECT count(*) FROM audit_events WHERE source_system IN ('opportunity-resources','opportunity-resources-install')")->fetchColumn();
$classifications = $pdo->query("SELECT DISTINCT ON (after_data->>'opportunity_id') after_data->>'opportunity_id' AS id,after_data->>'classification' AS classification FROM audit_events WHERE source_system='opportunity-origin-review' ORDER BY after_data->>'opportunity_id',occurred_at DESC,id DESC")->fetchAll(PDO::FETCH_ASSOC);
$auditCount = (int) $pdo->query("SELECT count(*) FROM audit_events WHERE source_system IN ('opportunity-management','opportunity-management-install')")->fetchColumn();
$installation = $pdo->query("SELECT id,actor_user_id,occurred_at,action,after_data FROM audit_events WHERE source_system='opportunity-management-install' ORDER BY occurred_at,id")->fetchAll(PDO::FETCH_ASSOC);
$backup = json_decode(file_get_contents($dir.'/opportunity-management-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
$checks = [];
$restored = $backup['restored_target'];
if (! str_ends_with($restored['database'], '_test') || (int) $restored['port'] !== 55441) {
throw new RuntimeException('Isolated history comparison required');
}
$baseline = new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$restored['database'], $c['username'], $c['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$baseline->exec('BEGIN TRANSACTION READ ONLY');
$oldEvents = $baseline->query("SELECT id,row_to_json(t)::text AS row FROM audit_events t WHERE source_system IS DISTINCT FROM 'opportunity-management' ORDER BY row_to_json(t)::text")->fetchAll(PDO::FETCH_KEY_PAIR);
if (hash('sha256', implode("\n", array_values($oldEvents))) !== $backup['table_hashes']['"public"."audit_events"']) {
throw new RuntimeException('Historical baseline drift');
}
$currentEvents = $pdo->query('SELECT id,row_to_json(t)::text AS row FROM audit_events t ORDER BY id')->fetchAll(PDO::FETCH_KEY_PAIR);
$missing = $changed = 0;
foreach ($oldEvents as $eventId => $row) {
if (! isset($currentEvents[$eventId])) {
$missing++;
} elseif ($currentEvents[$eventId] !== $row) {
$changed++;
}
}
$baseline->exec('ROLLBACK');
$historicalAudit = ['baseline_events' => count($oldEvents), 'missing' => $missing, 'changed' => $changed, 'additional_events' => count($currentEvents) - count($oldEvents)];
foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'opportunity_favourites', 'interest_requests', 'interest_submission_receipts', 'investment_messages', 'tasks', 'project_locations', 'cluster_projects', 'audit_events'] as $table) {
$key = '"public"."'.$table.'"';
if ($table === 'opportunities') {
@@ -25,6 +48,6 @@ foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'oppo
$checks[$table] = ['matches_preinstallation_backup' => hash('sha256', implode("\n", $rows)) === $backup['table_hashes'][$key], 'rows' => count($rows)];
}
$pdo->exec('ROLLBACK');
$result = ['captured_at' => gmdate('c'), 'identity' => $identity, 'installed' => $installed, 'migration_applied' => $migrationApplied, 'classifications' => $classifications, 'management_events' => $auditCount, 'installation_audit' => $installation, 'preservation' => $checks, 'active_writes' => 0];
$result = ['captured_at' => gmdate('c'), 'identity' => $identity, 'installed' => $installed, 'migration_applied' => $migrationApplied, 'resource_schema_installed' => $resourceSchemaInstalled, 'resource_events' => $resourceAuditCount, 'classifications' => $classifications, 'management_events' => $auditCount, 'installation_audit' => $installation, 'preservation' => $checks, 'historical_audit' => $historicalAudit, 'active_writes' => 0];
file_put_contents($dir.'/opportunity-management-active-read-only.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($result).PHP_EOL;
+12
View File
@@ -0,0 +1,12 @@
const {chromium}=require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs=require('node:fs'),assert=require('node:assert/strict'),zlib=require('node:zlib');
const base='http://127.0.0.1:18096',dir='storage/app/private/investmoz-v2/';
function png(){function crc(bytes){let c=0xffffffff;for(const b of bytes){c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}return (c^0xffffffff)>>>0;}function chunk(name,bytes){const n=Buffer.from(name),body=Buffer.concat([n,bytes]),head=Buffer.alloc(4),tail=Buffer.alloc(4);head.writeUInt32BE(bytes.length);tail.writeUInt32BE(crc(body));return Buffer.concat([head,body,tail]);}const hdr=Buffer.alloc(13);hdr.writeUInt32BE(500,0);hdr.writeUInt32BE(500,4);hdr[8]=8;hdr[9]=2;return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',hdr),chunk('IDAT',zlib.deflateSync(Buffer.alloc(500*1501))),chunk('IEND',Buffer.alloc(0))]);}
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe'});const errors=[];
async function session(viewport){const context=await browser.newContext({viewport});await context.route('**/*',r=>r.request().url().startsWith(base)||r.request().url().startsWith('data:')?r.continue():r.abort());const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(base+'/login');await page.getByLabel('Email address').fill('opportunity-manager@example.invalid');await page.getByLabel('Password',{exact:true}).fill('Opportunity-QA-2026!');await page.getByRole('button',{name:'Log in',exact:true}).click();await page.waitForURL('**/dashboard');return {context,page};}
const desktop=await session({width:1280,height:900}),page=desktop.page;await page.getByRole('link',{name:'Gestão de Oportunidades',exact:true}).click();await page.getByRole('link',{name:'Nova oportunidade',exact:true}).click();await page.getByLabel('Motivo da alteração').fill('Criação de recursos somente na cópia QA');await page.getByRole('button',{name:'Guardar rascunho',exact:true}).click();await page.waitForURL(/\/opportunities\/\d+\/edit$/);const id=Number(page.url().match(/(\d+)\/edit$/)[1]);assert(id>6);await page.getByRole('link',{name:'Imagens, documentos e localizações',exact:true}).click();await page.waitForURL('**/resources');
await page.getByLabel('Título',{exact:true}).fill('QA production location');await page.getByLabel('Papel da localização',{exact:true}).selectOption('production');const provinceSelect=page.getByLabel('Província',{exact:true});await provinceSelect.locator('option').nth(1).waitFor({state:'attached'});const province=await provinceSelect.locator('option').nth(1).getAttribute('value');await provinceSelect.selectOption(province);const districtSelect=page.getByLabel('Distrito',{exact:true});await districtSelect.locator('option').nth(1).waitFor({state:'attached'});const district=await districtSelect.locator('option').nth(1).getAttribute('value');await districtSelect.selectOption(district);await page.getByLabel('Distribuição (%)',{exact:true}).fill('60');await page.getByLabel('Localização principal',{exact:true}).check();await page.getByLabel('Motivo (20–1000 caracteres)').fill('Localização e distribuição declaradas em QA');await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();await page.locator('article').filter({hasText:'QA production location'}).waitFor();
await page.getByLabel('Título',{exact:true}).fill('QA market location');await page.getByLabel('Papel da localização',{exact:true}).selectOption('market');await provinceSelect.selectOption(province);await districtSelect.locator('option').nth(1).waitFor({state:'attached'});await districtSelect.selectOption(district);await page.getByLabel('Distribuição (%)',{exact:true}).fill('40');await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();await page.locator('article').filter({hasText:'QA market location'}).waitFor();assert.equal(await page.locator('article').count(),2);
await page.getByRole('button',{name:'Documentos',exact:true}).click();await page.getByLabel('Título',{exact:true}).fill('QA source document');await page.getByLabel('Original / substituir por nova versão').setInputFiles({name:'qa.pdf',mimeType:'application/pdf',buffer:Buffer.from('%PDF-1.7\nSynthetic QA document\n%%EOF')});await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();await page.locator('article').filter({hasText:'QA source document'}).waitFor();
await page.getByRole('button',{name:'Imagens',exact:true}).click();await page.getByLabel('Título',{exact:true}).fill('QA synthetic image');await page.getByLabel('Legenda',{exact:true}).fill('Synthetic image only used in isolated QA');await page.getByLabel('Texto alternativo',{exact:true}).fill('Black square generated for isolated QA');await page.getByLabel('Direitos de utilização',{exact:true}).selectOption('owned');await page.getByLabel('Original / substituir por nova versão').setInputFiles({name:'qa.png',mimeType:'image/png',buffer:png()});await page.getByRole('button',{name:'Guardar recurso',exact:true}).click();const imageArticle=page.locator('article').filter({hasText:'QA synthetic image'});await imageArticle.waitFor();await imageArticle.getByRole('button',{name:'Ver / editar / restaurar'}).click();await page.getByRole('button',{name:'Preparar WebP e miniaturas'}).click();await page.getByText(/GD\/WebP não disponível neste processo|Scan do original pendente ou inseguro/).waitFor();assert.equal(await page.getByLabel('Título',{exact:true}).inputValue(),'QA synthetic image');await page.screenshot({path:dir+'opportunity-resources-desktop.png',fullPage:true});await desktop.context.close();
const mobile=await session({width:390,height:844}),mp=mobile.page;await mp.goto(base+`/management/opportunities/${id}/resources`);await mp.evaluate(()=>localStorage.clear());await mp.reload();assert.equal(await mp.locator('article').count(),2);await mp.getByRole('button',{name:'Imagens',exact:true}).click();const mi=mp.locator('article').filter({hasText:'QA synthetic image'});await mi.getByRole('button',{name:'Ver / editar / restaurar'}).click();assert.equal(await mp.getByLabel('Texto alternativo',{exact:true}).inputValue(),'Black square generated for isolated QA');await mp.getByLabel('Motivo (20–1000 caracteres)').fill('Exclusão lógica de imagem somente no ensaio QA');await mp.getByLabel('Confirmar exclusão lógica / restauração').check();await mp.getByRole('button',{name:'Excluir recurso logicamente'}).click();await mp.getByText('eliminado logicamente',{exact:false}).first().waitFor();await mi.getByRole('button',{name:'Ver / editar / restaurar'}).click();await mp.getByLabel('Confirmar exclusão lógica / restauração').check();await mp.getByRole('button',{name:'Restaurar recurso'}).click();await mi.getByText(/rascunho/).waitFor();await mp.screenshot({path:dir+'opportunity-resources-mobile.png',fullPage:true});assert(await mp.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));assert.deepEqual(errors,[]);const proof={recorded_at:new Date().toISOString(),id,desktop:true,mobile:true,new_session_recovery:true,multiple_locations:true,documents:true,images:true,scan_failure_visible:true,soft_delete_restore:true,errors,active_writes:0};fs.writeFileSync(dir+'opportunity-resources-browser.json',JSON.stringify(proof,null,2));console.log(JSON.stringify(proof));await browser.close();})().catch(e=>{console.error(e);process.exit(1)});
+6 -1
View File
@@ -63,9 +63,14 @@ if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'verify') {
$columns = array_diff(array_keys((array) DB::selectOne('SELECT * FROM opportunities LIMIT 1')), []);
$projection = implode(',', array_map(fn ($column) => '"'.$column.'"', $columns));
$checks = [];
foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'opportunity_favourites', 'interest_requests', 'interest_submission_receipts', 'investment_messages', 'tasks', 'project_locations', 'cluster_projects', 'audit_events'] as $table) {
foreach (['opportunities', 'opportunity_projects', 'opportunity_clusters', 'opportunity_locations', 'geo_locations', 'documents', 'opportunity_favourites', 'interest_requests', 'interest_submission_receipts', 'investment_messages', 'tasks', 'project_locations', 'cluster_projects', 'audit_events'] as $table) {
$key = '"public"."'.$table.'"';
$query = $table === 'opportunities' ? '(SELECT '.$projection.' FROM opportunities WHERE management_creation_key IS NULL)' : ($table === 'audit_events' ? "(SELECT * FROM audit_events WHERE source_system IS DISTINCT FROM 'opportunity-resources' AND source_system IS DISTINCT FROM 'opportunity-management')" : $key);
if ($table === 'documents') {
$oldDocumentColumns = array_diff(array_keys((array) DB::selectOne('SELECT * FROM documents LIMIT 1')), ['storage_encryption']);
$documentProjection = implode(',', array_map(fn ($column) => '"'.$column.'"', $oldDocumentColumns));
$query = '(SELECT '.$documentProjection." FROM documents WHERE source_system NOT IN ('opportunity-resources','opportunity-image-derivatives'))";
}
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM '.$query.' t ORDER BY row_to_json(t)::text');
$checks[$table] = hash('sha256', implode("\n", array_column($rows, 'row'))) === $backup['table_hashes'][$key];
if (! $checks[$table]) {