oportunidades 2
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-09 08:48:42 +02:00
parent 7ecfe3d543
commit 60784a2ed2
14 changed files with 1496 additions and 125 deletions
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\InvestmentGeography;
use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceInstallation;
use App\Services\OpportunityResources;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\HeaderUtils;
class OpportunityResourceController extends Controller
{
public function installation(Request $request, OpportunityResourceInstallation $service)
{
$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)
{
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
$service->apply($request->user(), $data['hash']);
return back()->with('success', 'Recursos instalados; nenhum ficheiro publicado ou oportunidade submetida.');
}
public function index(int $id, OpportunityResources $service)
{
$opportunity = Opportunity::withTrashed()->findOrFail($id);
abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.');
$resources = [];
foreach (OpportunityResources::KINDS as $kind) {
$resources[$kind] = $service->listing($id, $kind);
}
foreach ($resources['locations'] as $location) {
$p = $location->payload;
$location->territoryPath = app(InvestmentGeography::class)->path($p['locality_id'] ?? $p['admin_post_id'] ?? $p['district_id'] ?? $p['province_id'] ?? null, true);
}
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)
{
$input = $request->all();
if (is_string($input['payload'] ?? null)) {
$input['payload'] = json_decode($input['payload'], true);
}
$record = $service->save($request->user(), $id, $kind, $input, $request->file('file'), $resourceId);
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)
{
$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)
{
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$opportunity = Opportunity::findOrFail($id);
abort_if($opportunity->status === 'archived', 404);
$record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']);
abort_unless($record && $record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.');
$disk = Storage::disk('local');
$path = $disk->path($record->storage_key);
abort_unless($disk->exists($record->storage_key), 409, 'Ficheiro indisponível.');
$bytes = $record->storage_encryption === 'laravel' ? Crypt::decryptString($disk->get($record->storage_key)) : $disk->get($record->storage_key);
abort_unless(hash_equals(trim($record->sha256), hash('sha256', $bytes)), 409, 'Integridade do ficheiro divergente.');
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
* @return list<string>
*/
private function assessment(array $resources): array
{
$issues = [];
$locations = array_values(array_filter($resources['locations'], fn ($r) => ! $r->deleted_at));
if (count($locations) < 1) {
$issues[] = 'Localização operacional ainda não preparada; ligações históricas não são convertidas automaticamente.';
}
if (count(array_filter($locations, fn ($r) => $r->payload['is_primary'] ?? false)) !== 1) {
$issues[] = 'Escolha exatamente uma localização principal.';
}
$allocated = array_filter($locations, fn ($r) => isset($r->payload['allocation_percentage']));
if ($allocated && (count($allocated) !== count($locations) || abs(array_sum(array_map(fn ($r) => (float) $r->payload['allocation_percentage'], $allocated)) - 100) > 0.01)) {
$issues[] = 'A distribuição deve abranger todas as localizações e somar 100% (tolerância 0,01).';
}
$provinces = [];
$districts = [];
foreach ($locations as $location) {
$p = $location->payload;
if (empty($p['province_id']) || empty($p['district_id']) || empty($p['role'])) {
$issues[] = 'Uma localização tem província, distrito ou papel pendente.';
}
if (! empty($p['district_id'])) {
$districts[$p['district_id']] = true;
}
if (! empty($p['province_id'])) {
$provinces[$p['province_id']] = true;
}
if (($p['role'] ?? null) === 'other' && empty($p['role_description'])) {
$issues[] = 'Papel outro exige descrição.';
}
if (empty($p['source_reference'])) {
$issues[] = 'Localização com rastreabilidade por recuperar; aceitação oficial anterior preservada.';
}
if (! empty($p['geometry'])) {
$issues[] = 'Compatibilidade espacial depende de limite oficial correspondente; geometria não confirma disponibilidade.';
}
if (isset($p['area_value']) !== isset($p['area_unit'])) {
$issues[] = 'Área exige valor e unidade juntos.';
}
}
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.';
}
foreach (['images', 'documents'] as $kind) {
foreach ($resources[$kind] as $r) {
if ($r->deleted_at) {
continue;
}
if ($r->file->malware_scan_status !== 'clean') {
$issues[] = 'Anexo '.$r->file->file_name.': scan '.$r->file->malware_scan_status.'; não é evidência de segurança.';
}
$required = $kind === 'images' ? ['title', 'caption', 'alt_text', 'author_source', 'rights_status'] : ['category', 'issuer', 'edition', 'classification'];
foreach ($required as $field) {
if (empty($r->payload[$field])) {
$issues[] = 'Anexo '.$r->file->file_name.': '.$field.' pendente.';
}
}
if ($kind === 'images') {
$issues[] = 'Imagem '.$r->file->file_name.': revisão, derivadas sem EXIF e aprovação de divulgação 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.';
}
}
}
}
$issues[] = 'Esta conferência não submete nem publica; workflow final, revisão humana e derivados seguros permanecem separados.';
return array_values(array_unique($issues));
}
}
+36 -19
View File
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -67,27 +68,43 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
$disk = Storage::disk('local');
$root = $disk->path('');
$path = $disk->path($document->storage_key);
$beforeHash = $this->fileHash($path);
if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) {
$this->record($document, [
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'ClamAV',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null,
], $startedAt, 'inconclusive');
$temporary = null;
try {
if (($document->storage_encryption ?? null) === 'laravel') {
$temporary = tempnam($root, 'scan-private-');
if ($temporary === false) {
throw new \RuntimeException('Private scan workspace unavailable');
}
chmod($temporary, 0600);
file_put_contents($temporary, Crypt::decryptString($disk->get($document->storage_key)));
$path = $temporary;
}
$beforeHash = $this->fileHash($path);
if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) {
$this->record($document, [
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'ClamAV',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null,
], $startedAt, 'inconclusive');
return;
return;
}
$result = $scanner->scan($path, (int) ($document->size_bytes ?? filesize($path)), $root);
$afterHash = $this->fileHash($path);
if ($afterHash === null || ! hash_equals(trim($document->sha256), $afterHash)) {
$result['result'] = 'inconclusive';
$result['reason_code'] = 'file_changed_during_scan';
$result['detail'] = null;
}
$result['file_sha256'] = $afterHash;
$this->record($document, $result, $startedAt, $result['result']);
} finally {
if (is_string($temporary) && is_file($temporary)) {
unlink($temporary);
}
}
$result = $scanner->scan($path, (int) ($document->size_bytes ?? filesize($path)), $root);
$afterHash = $this->fileHash($path);
if ($afterHash === null || ! hash_equals(trim($document->sha256), $afterHash)) {
$result['result'] = 'inconclusive';
$result['reason_code'] = 'file_changed_during_scan';
$result['detail'] = null;
}
$result['file_sha256'] = $afterHash;
$this->record($document, $result, $startedAt, $result['result']);
}
public function failed(Throwable $exception): void
@@ -0,0 +1,53 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
class OpportunityResourceInstallation
{
public const MIGRATION = '2026_10_09_030000_extend_opportunity_resources';
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')];
return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
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) : [];
$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);
$installed = app(OpportunityResources::class)->installed();
return ['installed' => $installed, 'ready' => $ready, 'target' => $identity, 'code_hash' => $code, 'hash' => hash('sha256', json_encode([$identity, $code, $ready, $installed], JSON_THROW_ON_ERROR)), 'migration' => self::MIGRATION, 'backupDate' => $proof['verified_at'] ?? null, 'changes' => ['Recursos privados de preparação ligados às oportunidades existentes, documents e geo_locations; nenhum dado existente alterado.', 'Originais cifrados; scan pendente bloqueia acesso aos binários, não preparação de metadados/localizações.', 'Versões, exclusão lógica e restauro com auditoria; nenhuma publicação automática.', 'Pendentes: ClamD real, derivadas seguras/revisão/publicação e compatibilidade com limites oficiais onde ausentes.'], 'rollback' => 'Reversão apenas com tabelas vazias e sem documentos cifrados; após utilização, preservar e reconciliar versões antes de uma ação autorizada.'];
}
public function apply(User $actor, string $hash): void
{
abort_unless($actor->can('manage-portal'), 403);
DB::transaction(function () use ($actor, $hash) {
DB::select('SELECT pg_advisory_xact_lock(91026,100000)');
$p = $this->preview();
abort_unless(hash_equals($p['hash'], $hash), 409, 'Plano alterado; reveja.');
if ($p['installed']) {
return;
}
abort_unless($p['ready'], 422, 'Provas desta versão necessárias.');
config(['opportunity_resources.authenticated_installation' => true]);
try {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500);
} finally {
config(['opportunity_resources.authenticated_installation' => false]);
}
DB::table('audit_events')->insert(['source_system' => 'opportunity-resources-install', 'entity_type' => 'installation', 'action' => 'opportunity_resources_installed', 'actor_user_id' => $actor->id, 'after_data' => json_encode(['code_hash' => $p['code_hash'], 'migration' => self::MIGRATION])]);
});
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
namespace App\Services;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class OpportunityResources
{
public const KINDS = ['documents', 'images', 'locations'];
public function installed(): bool
{
return Schema::hasTable('opportunity_images');
}
public function table(string $kind): string
{
abort_unless(in_array($kind, self::KINDS, true), 404);
return 'opportunity_'.($kind === 'locations' ? 'location_drafts' : $kind);
}
/** @return list<object> */
public function listing(int $opportunityId, string $kind): array
{
$query = DB::table($this->table($kind))->where('opportunity_id', $opportunityId)->orderBy('created_at')->orderBy('id');
$rows = $query->get()->all();
foreach ($rows as $row) {
$row->payload = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
if ($kind !== 'locations') {
$row->file = DB::table('documents')->where('id', $row->document_id)->first(['file_name', 'media_type', 'size_bytes', 'sha256', 'malware_scan_status', 'malware_failure_reason', 'malware_engine']);
}
}
return $rows;
}
/** @param array<string,mixed> $input */
public function save(User $actor, int $opportunityId, string $kind, array $input, ?UploadedFile $file = null, ?string $id = null): object
{
abort_unless($actor->can('manage-portal'), 403);
abort_unless($this->installed(), 503);
$table = $this->table($kind);
$data = Validator::make($input, [
'etag' => 'required|string|size:64', 'version' => ($id ? 'required' : 'nullable').'|integer|min:1',
'creation_key' => ($id ? 'nullable' : 'required').'|uuid', 'reason' => 'required|string|min:20|max:1000',
'payload' => 'required|array',
])->validate();
$payload = $this->validatePayload($kind, $data['payload'], $opportunityId);
$stored = null;
$documentId = null;
if ($file) {
[$stored, $documentId] = $this->store($kind, $file);
} elseif (! $id && $kind !== 'locations') {
throw ValidationException::withMessages(['file' => 'Selecione o ficheiro original.']);
}
try {
$result = DB::transaction(function () use ($actor, $opportunityId, $kind, $table, $data, $payload, $file, $id, $stored, $documentId) {
$opportunity = $this->lock($opportunityId, $data['etag']);
$record = $id ? DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first() : null;
abort_if($id && ! $record, 404);
abort_if($record && ($record->deleted_at || (int) $record->version !== (int) $data['version']), 409, 'Recurso alterado ou eliminado; preserve o preenchimento e reveja a versão.');
$resourceId = $id ?? $data['creation_key'];
abort_if(! $id && DB::table($table)->where('id', $resourceId)->exists(), 409, 'Esta criação já foi registada; recupere o recurso existente.');
$active = DB::table($table)->where('opportunity_id', $opportunityId)->whereNull('deleted_at');
abort_if(! $id && $active->count() >= ($kind === 'locations' ? 50 : ($kind === 'images' ? 20 : 100)), 422, 'Limite de recursos atingido.');
if ($kind === 'locations') {
$others = (clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get();
foreach ($others as $other) {
$otherPayload = json_decode($other->payload, true);
$keys = ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'role', 'operational_unit'];
abort_if(array_intersect_key($payload, array_flip($keys)) == array_intersect_key($otherPayload, array_flip($keys)), 422, 'Esta localização, papel e unidade já existem.');
abort_if(($payload['is_primary'] ?? false) && ($otherPayload['is_primary'] ?? false), 422, 'Já existe localização principal; altere a anterior antes de escolher outra.');
}
}
if ($kind === 'images') {
foreach ((clone $active)->when($id, fn ($q) => $q->where('id', '!=', $id))->get() as $other) {
$p = json_decode($other->payload, true);
abort_if(($payload['is_cover'] ?? false) && ($p['is_cover'] ?? false), 422, 'Já existe uma capa; retire essa seleção antes de escolher outra.');
abort_if(isset($payload['sort_order'], $p['sort_order']) && $payload['sort_order'] === $p['sort_order'], 422, 'A ordem já está ocupada.');
}
}
if ($file) {
$hash = hash_file('sha256', Storage::disk('local')->path($stored));
$duplicate = DB::table($table.' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.opportunity_id', $opportunityId)->where('d.sha256', $hash)->when($id, fn ($q) => $q->where('r.id', '!=', $id))->exists();
if ($duplicate) {
throw ValidationException::withMessages(['file' => 'Os mesmos bytes já estão ligados a esta oportunidade; recupere o recurso, sem duplicar.']);
}
$original = Storage::disk('local')->get($stored);
abort_unless(Storage::disk('local')->put($stored, Crypt::encryptString($original)), 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()];
if ($kind !== 'locations' && $documentId) {
$values['document_id'] = $documentId;
}
if ($record) {
DB::table($table)->where('id', $id)->update($values);
} else {
DB::table($table)->insert([...$values, 'id' => $resourceId, 'opportunity_id' => $opportunityId, 'created_by' => $actor->id, 'created_at' => now()]);
}
$after = DB::table($table)->where('id', $resourceId)->first();
$this->audit($actor, $kind, $record, $after, $data['reason'], 'saved');
$opportunity->management_version++;
$opportunity->save();
if ($documentId) {
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
}
return $after;
});
} catch (\Throwable $error) {
if ($stored) {
Storage::disk('local')->delete($stored);
}
throw $error;
}
return $result;
}
/** @param array<string,mixed> $input */
public function lifecycle(User $actor, int $opportunityId, string $kind, string $id, array $input): void
{
abort_unless($actor->can('manage-portal'), 403);
$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_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') {
$this->validatePayload($kind, $payload, $opportunityId);
// Restoration never silently reclaims a primary/cover/order occupied by another resource.
if ($kind === 'images') {
$payload['is_cover'] = false;
unset($payload['sort_order']);
}
if ($kind === 'locations') {
$payload['is_primary'] = false;
}
}
if ($kind === 'locations' && $data['action'] === 'delete') {
foreach (DB::table('opportunity_images')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')->get() as $image) {
abort_if((json_decode($image->payload, true)['location_id'] ?? null) === $id, 422, 'Uma imagem usa esta localização; corrija a ligação antes de excluir.');
}
}
DB::table($table)->where('id', $id)->update(['deleted_at' => $data['action'] === 'delete' ? now() : null, 'version' => $record->version + 1, 'payload' => json_encode($payload), 'updated_at' => now()]);
$this->audit($actor, $kind, $record, DB::table($table)->where('id', $id)->first(), $data['reason'], $data['action']);
$opportunity->management_version++;
$opportunity->save();
});
}
private function lock(int $id, string $etag): Opportunity
{
$record = Opportunity::withTrashed()->whereKey($id)->lockForUpdate()->firstOrFail();
abort_if($record->trashed() || $record->status === 'archived', 409, 'Restaure a oportunidade antes de editar recursos.');
if (! hash_equals(app(OpportunityManagement::class)->etag($record), $etag)) {
throw new HttpResponseException(response()->json(['message' => 'Conflito de versão.', 'errors' => ['etag' => ['Oportunidade alterada. Preserve os dados e recarregue antes de reconciliar.']]], 409));
}
return $record;
}
/** @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function validatePayload(string $kind, array $payload, int $opportunityId): array
{
$rules = ['title' => 'nullable|string|max:120', 'source_reference' => 'nullable|string|max:2000', 'source_date' => 'nullable|date_format:Y-m-d', 'visibility' => 'nullable|in:internal,authenticated,public'];
if ($kind === 'locations') {
$rules += ['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid', 'role' => 'nullable|in:headquarters,production,raw_material,storage,logistics,market,support,other', 'role_description' => 'nullable|string|max:1000', 'operational_unit' => 'nullable|string|max:120', 'is_primary' => 'nullable|boolean', 'address' => 'nullable|string|max:300', 'geometry' => 'nullable|array', 'area_value' => 'nullable|numeric|gt:0', 'area_unit' => 'nullable|in:sqm,ha,sqkm', 'allocation_percentage' => 'nullable|numeric|between:0,100', 'territorial_purpose' => 'nullable|in:implementation,context,impact,coverage', 'multi_district_rationale' => 'nullable|string|max:1000'];
} elseif ($kind === 'images') {
$rules += ['role' => 'nullable|in:cover,gallery,location,asset,product,plan,rendering,evidence,other', 'role_description' => 'nullable|string|max:1000', 'is_cover' => 'nullable|boolean', 'caption' => 'nullable|string|max:300', 'alt_text' => 'nullable|string|max:250', 'location_id' => ['nullable', 'uuid', Rule::exists('opportunity_location_drafts', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'captured_at' => 'nullable|date_format:Y-m-d|before_or_equal:today', 'author_source' => 'nullable|string|max:150', 'rights_status' => 'nullable|in:owned,licensed,authorized,public_domain', 'rights_document_id' => ['nullable', 'uuid', Rule::exists('opportunity_documents', 'id')->where('opportunity_id', $opportunityId)->whereNull('deleted_at')], 'rights_valid_until' => 'nullable|date_format:Y-m-d', 'sort_order' => 'nullable|integer|min:0'];
} else {
$rules += ['category' => 'nullable|string|max:100', 'issuer' => 'nullable|string|max:255', 'edition' => 'nullable|string|max:120', 'valid_until' => 'nullable|date_format:Y-m-d', 'classification' => 'nullable|in:public,internal,confidential,restricted', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'];
}
$data = Validator::make($payload, $rules)->validate();
if ($kind === 'locations') {
app(InvestmentGeography::class)->selection($data, true);
if (! empty($data['geometry'])) {
$geometry = $data['geometry'];
if (! in_array($geometry['type'] ?? '', ['Point', 'LineString', 'Polygon', 'MultiPolygon'], true) || isset($geometry['crs'])) {
throw ValidationException::withMessages(['geometry' => 'Use GeoJSON Point/LineString/Polygon/MultiPolygon EPSG:4326.']);
}
try {
$check = DB::selectOne('WITH g AS (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) AS geom) SELECT ST_IsValid(geom) AS valid, ST_IsEmpty(geom) AS empty, ST_XMin(Box3D(geom)) AS xmin,ST_XMax(Box3D(geom)) AS xmax,ST_YMin(Box3D(geom)) AS ymin,ST_YMax(Box3D(geom)) AS ymax FROM g', [json_encode($geometry)]);
if (! $check->valid || $check->empty || $check->xmin < -180 || $check->xmax > 180 || $check->ymin < -90 || $check->ymax > 90) {
throw new \RuntimeException;
}
} catch (\Throwable) {
throw ValidationException::withMessages(['geometry' => 'Geometria inválida, vazia ou fora de EPSG:4326.']);
}
$data['spatial_compatibility'] = 'pending_official_boundary';
}
}
return $data;
}
/** @return array{string,string} */
private function store(string $kind, UploadedFile $file): array
{
$mimes = $kind === 'images' ? 'image/jpeg,image/png,image/webp' : 'application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/png,image/jpeg';
Validator::make(['file' => $file], ['file' => ['required', 'file', 'mimetypes:'.$mimes, 'max:'.($kind === 'images' ? 10240 : 20480)]])->validate();
if (str_starts_with((string) $file->getMimeType(), 'image/')) {
$size = @getimagesize($file->getRealPath());
if (! $size || $size[0] < 400 || $size[1] < 400) {
throw ValidationException::withMessages(['file' => 'Imagem inválida ou inferior a 400 × 400 píxeis.']);
}
$bytes = file_get_contents($file->getRealPath());
if (preg_match('/<\?(?:php|=)|<script\b/i', $bytes) || (str_contains($bytes, 'acTL') && $file->getMimeType() === 'image/png') || (str_contains($bytes, 'ANIM') && $file->getMimeType() === 'image/webp')) {
throw ValidationException::withMessages(['file' => 'Imagem animada ou com conteúdo executável não permitida.']);
}
}
if (str_contains((string) $file->getMimeType(), 'openxmlformats')) {
$zip = new \ZipArchive;
if ($zip->open($file->getRealPath()) !== true) {
throw ValidationException::withMessages(['file' => 'Pacote Office inválido.']);
}
$required = str_contains($file->getMimeType(), 'wordprocessingml') ? 'word/document.xml' : 'xl/workbook.xml';
$valid = $zip->locateName($required) !== false && $zip->locateName('[Content_Types].xml') !== false;
$zip->close();
if (! $valid) {
throw ValidationException::withMessages(['file' => 'Conteúdo Office não corresponde ao formato indicado.']);
}
}
$id = (string) Str::uuid();
$key = $file->storeAs('opportunity-resources', $id, 'local');
abort_unless(is_string($key), 500, 'Falha ao guardar original privado.');
return [$key, $id];
}
private function audit(User $actor, string $kind, ?object $before, object $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))])]);
}
}
@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): 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('QA isolada ou instalação autenticada necessária.');
}
// Metadata belongs to the existing opportunity. Binaries and territories stay in their existing repositories.
Schema::table('documents', function (Blueprint $t) {
$t->string('storage_encryption', 30)->nullable();
});
foreach (['documents', 'images', 'location_drafts'] as $kind) {
Schema::create('opportunity_'.$kind, function (Blueprint $t) use ($kind) {
$t->uuid('id')->primary();
$t->foreignId('opportunity_id')->constrained('opportunities')->restrictOnDelete();
if ($kind !== 'location_drafts') {
$t->foreignUuid('document_id')->constrained('documents')->restrictOnDelete();
}
$t->jsonb('payload');
$t->unsignedInteger('version')->default(1);
$t->foreignId('created_by')->constrained('users')->restrictOnDelete();
$t->timestampsTz();
$t->softDeletesTz();
$t->index(['opportunity_id', 'deleted_at']);
});
}
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION guard_opportunity_resource_delete() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Preservar recursos, versões e histórico; usar exclusão lógica';
END $$;
CREATE TRIGGER protect_opportunity_documents BEFORE DELETE ON opportunity_documents FOR EACH ROW EXECUTE FUNCTION guard_opportunity_resource_delete();
CREATE TRIGGER protect_opportunity_images BEFORE DELETE ON opportunity_images FOR EACH ROW EXECUTE FUNCTION guard_opportunity_resource_delete();
CREATE TRIGGER protect_opportunity_location_drafts BEFORE DELETE ON opportunity_location_drafts FOR EACH ROW EXECUTE FUNCTION guard_opportunity_resource_delete();
SQL);
}
public function down(): void
{
foreach (['documents', 'images', 'location_drafts'] as $kind) {
if (DB::table('opportunity_'.$kind)->exists()) {
throw new RuntimeException('Preservar recursos antes de reversão.');
}
}
foreach (['documents', 'images', 'location_drafts'] as $kind) {
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');
});
}
};
+4
View File
@@ -217,3 +217,7 @@ Todos mantêm estado editorial `published` e inclusão no filtro público do cat
**Evidência:** 72 testes/1 164 asserções, TypeScript/PHPStan/build e percurso desktop/móvel passaram. Backup/restauro de 288 tabelas/cinco ficheiros e reversão antes de uso comprovados em `desk_restore_20261009_060826_test:55441`. Plano único `/management/opportunity-management-install`, válido até 10/10/2026 06:08:36 UTC; aplicação exclusivamente autenticada. Leitura ativa em 09/10/2026 06:13:34 UTC: migration ausente, zero eventos desta gestão e seis classificações reais mantidas. **Preparado, não aplicado na base ativa.**
**Pendentes da especificação:** catálogos completos/dropdowns pesquisáveis, maturidade de oito etapas, wizard/autosave, imagens/documentos/multilocalização completa, validação final, segregação de papéis, revisão/submissão/aprovação/publicação e perfis especializados de investimento/imobiliário/direitos fundiários/transações. Matriz por secção no relatório; CRUD não certifica REQ01–REQ17/DT01–DT15 integrais. Cobertura do inventário, questões jurídicas, ClamD real e primeiro pedido real continuam separados.
### 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.
@@ -54,3 +54,9 @@ Backup verificado em **09/10/2026 06:08:36 UTC**, válido para esta pré-visuali
Reversão direta `down` apenas antes de novos eventos ou drafts incompletos. Depois de uso, preservar/reconciliar dados e preparar reversão autorizada. Restauro integral exige janela e reconciliação de escritas posteriores; ensaio em cópia não autoriza sobrescrever a base ativa.
Pendências separadas: cobertura do inventário, questões jurídicas específicas, ClamD real, referências institucionais concretas e primeiro pedido real no Balcão. Nenhuma foi concluída por estes testes.
## Confirmação ativa — 9 de outubro, 06:34:48 UTC
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.
@@ -1,10 +1,10 @@
import { Link, router } from '@inertiajs/react';
import { useState, useEffect } from 'react';
import { Link, router } from "@inertiajs/react";
import { useState, useEffect } from "react";
import {
TerritoryLevel,
type TerritoryOption,
} from '@/components/investment-territory';
import { Management, Field, button, control } from './shared';
} from "@/components/investment-territory";
import { Management, Field, button, control } from "./shared";
type Values = Record<string, string | number | null>;
type Entry = {
id: number;
@@ -47,43 +47,43 @@ export default function OpportunityDraft({
});
const [busy, setBusy] = useState(false),
[errors, setErrors] = useState<Record<string, string>>({}),
[message, setMessage] = useState(''),
[reason, setReason] = useState(''),
[message, setMessage] = useState(""),
[reason, setReason] = useState(""),
[confirm, setConfirm] = useState(false),
[token, setToken] = useState(etag);
useEffect(() => setToken(etag), [etag]);
const [path, setPath] = useState(territoryPath);
const unavailable = Boolean(
record?.deleted_at || record?.status === 'archived',
record?.deleted_at || record?.status === "archived",
);
const set = (key: string, value: string) =>
setData((d) => ({ ...d, [key]: value }));
async function send(action: 'save' | 'delete' | 'restore') {
async function send(action: "save" | "delete" | "restore") {
if (busy) return;
setBusy(true);
setErrors({});
setMessage('');
setMessage("");
try {
const cookie = document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='));
.split("; ")
.find((c) => c.startsWith("XSRF-TOKEN="));
const url =
action === 'save'
action === "save"
? record
? `/management/opportunities/${record.id}/draft`
: '/management/opportunities/drafts'
: "/management/opportunities/drafts"
: `/management/opportunities/${record!.id}/lifecycle`;
const response = await fetch(url, {
method: action === 'save' && record ? 'PUT' : 'POST',
method: action === "save" && record ? "PUT" : "POST",
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': cookie
Accept: "application/json",
"Content-Type": "application/json",
"X-XSRF-TOKEN": cookie
? decodeURIComponent(cookie.slice(11))
: '',
: "",
},
body: JSON.stringify(
action === 'save'
action === "save"
? { ...data, etag: token, reason }
: { etag: token, action, reason, confirm },
),
@@ -94,13 +94,13 @@ export default function OpportunityDraft({
Object.fromEntries(
Object.entries(result.errors ?? {}).map(([k, v]) => [
k,
Array.isArray(v) ? v.join(' ') : String(v),
Array.isArray(v) ? v.join(" ") : String(v),
]),
),
);
setMessage(
result.message ??
'Operação falhou; os dados preenchidos foram preservados.',
"Operação falhou; os dados preenchidos foram preservados.",
);
return;
}
@@ -108,15 +108,15 @@ export default function OpportunityDraft({
if (result.etag) setToken(result.etag);
if (!record)
router.visit(`/management/opportunities/${result.id}/edit`);
else if (action !== 'save') router.reload();
else if (action !== "save") router.reload();
} catch {
setMessage(
'Resposta indisponível. Os dados foram preservados; confira o registo no servidor antes de repetir.',
"Resposta indisponível. Os dados foram preservados; confira o registo no servidor antes de repetir.",
);
} finally {
setBusy(false);
requestAnimationFrame(() =>
document.getElementById('opportunity-result')?.focus(),
document.getElementById("opportunity-result")?.focus(),
);
}
}
@@ -124,8 +124,8 @@ export default function OpportunityDraft({
<Management
title={
record
? `${readOnly ? 'Ver' : 'Editar'} oportunidade ${record.reference}`
: 'Nova oportunidade'
? `${readOnly ? "Ver" : "Editar"} oportunidade ${record.reference}`
: "Nova oportunidade"
}
>
<Link href="/management/opportunities">Voltar à gestão</Link>
@@ -143,13 +143,13 @@ export default function OpportunityDraft({
<div
id="opportunity-result"
tabIndex={-1}
role={Object.keys(errors).length ? 'alert' : 'status'}
role={Object.keys(errors).length ? "alert" : "status"}
className="rounded border p-3"
>
{message}
{errors.etag && (
<p>
{errors.etag}{' '}
{errors.etag}{" "}
<a
target="_blank"
rel="noreferrer"
@@ -164,7 +164,7 @@ export default function OpportunityDraft({
className="max-w-3xl space-y-4"
onSubmit={(e) => {
e.preventDefault();
void send('save');
void send("save");
}}
>
<fieldset
@@ -179,16 +179,16 @@ export default function OpportunityDraft({
</p>
{(
[
'province_id',
'district_id',
'admin_post_id',
'locality_id',
"province_id",
"district_id",
"admin_post_id",
"locality_id",
] as const
).map((key, index) => (
<TerritoryLevel
key={`${key}-${path[index - 1]?.id ?? ''}`}
key={`${key}-${path[index - 1]?.id ?? ""}`}
index={index}
parent={path[index - 1]?.id ?? ''}
parent={path[index - 1]?.id ?? ""}
selected={path[index]}
endpoint="/management/territory-options"
fieldError={errors[key]}
@@ -200,10 +200,10 @@ export default function OpportunityDraft({
setPath(next);
setData((previous) => ({
...previous,
province_id: next[0]?.id ?? '',
district_id: next[1]?.id ?? '',
admin_post_id: next[2]?.id ?? '',
locality_id: next[3]?.id ?? '',
province_id: next[0]?.id ?? "",
district_id: next[1]?.id ?? "",
admin_post_id: next[2]?.id ?? "",
locality_id: next[3]?.id ?? "",
}));
}}
/>
@@ -215,9 +215,9 @@ export default function OpportunityDraft({
>
<select
className={control}
value={String(data.opportunity_category ?? '')}
value={String(data.opportunity_category ?? "")}
onChange={(e) =>
set('opportunity_category', e.target.value)
set("opportunity_category", e.target.value)
}
>
<option value="">Por indicar</option>
@@ -227,22 +227,22 @@ export default function OpportunityDraft({
</select>
</Field>
{Object.entries({
title: 'Título oficial',
summary: 'Resumo',
description: 'Descrição',
promoter_name: 'Promotor',
title: "Título oficial",
summary: "Resumo",
description: "Descrição",
promoter_name: "Promotor",
}).map(([key, label]) => (
<Field key={key} label={label} error={errors[key]}>
{key === 'description' || key === 'summary' ? (
{key === "description" || key === "summary" ? (
<textarea
className={control}
value={String(data[key] ?? '')}
value={String(data[key] ?? "")}
onChange={(e) => set(key, e.target.value)}
/>
) : (
<input
className={control}
value={String(data[key] ?? '')}
value={String(data[key] ?? "")}
onChange={(e) => set(key, e.target.value)}
/>
)}
@@ -251,8 +251,8 @@ export default function OpportunityDraft({
<Field label="Sector" error={errors.sector_id}>
<select
className={control}
value={String(data.sector_id ?? '')}
onChange={(e) => set('sector_id', e.target.value)}
value={String(data.sector_id ?? "")}
onChange={(e) => set("sector_id", e.target.value)}
>
<option value="">Por indicar</option>
{sectors.map((s) => (
@@ -268,21 +268,21 @@ export default function OpportunityDraft({
>
<select
className={control}
value={String(data.promoter_type ?? '')}
value={String(data.promoter_type ?? "")}
onChange={(e) =>
set('promoter_type', e.target.value)
set("promoter_type", e.target.value)
}
>
<option value="">Por indicar</option>
{values.promoter_type &&
![
'public',
'private',
'ppp',
'cooperative',
'association',
'community',
'other',
"public",
"private",
"ppp",
"cooperative",
"association",
"community",
"other",
].includes(String(values.promoter_type)) && (
<option
value={String(values.promoter_type)}
@@ -292,13 +292,13 @@ export default function OpportunityDraft({
</option>
)}
{[
'public',
'private',
'ppp',
'cooperative',
'association',
'community',
'other',
"public",
"private",
"ppp",
"cooperative",
"association",
"community",
"other",
].map((c) => (
<option key={c}>{c}</option>
))}
@@ -310,19 +310,19 @@ export default function OpportunityDraft({
>
<select
className={control}
value={String(data.maturity_level ?? '')}
value={String(data.maturity_level ?? "")}
onChange={(e) =>
set('maturity_level', e.target.value)
set("maturity_level", e.target.value)
}
>
<option value="">Por indicar</option>
{[
'Ideia identificada',
'Pré-estudo disponível',
'Estudo de viabilidade em curso',
'Projecto estruturado',
'Pronto para investimento',
'Em negociação ou implementação',
"Ideia identificada",
"Pré-estudo disponível",
"Estudo de viabilidade em curso",
"Projecto estruturado",
"Pronto para investimento",
"Em negociação ou implementação",
].map((c, index) => (
<option key={c} value={index + 1}>
{c}
@@ -336,9 +336,9 @@ export default function OpportunityDraft({
>
<input
className={control}
value={String(data.financing_modality ?? '')}
value={String(data.financing_modality ?? "")}
onChange={(e) =>
set('financing_modality', e.target.value)
set("financing_modality", e.target.value)
}
/>
</Field>
@@ -348,13 +348,13 @@ export default function OpportunityDraft({
>
<textarea
className={control}
value={String(data.expected_impact ?? '')}
value={String(data.expected_impact ?? "")}
onChange={(e) =>
set('expected_impact', e.target.value)
set("expected_impact", e.target.value)
}
/>
</Field>
{(data.opportunity_category === 'investment_project' ||
{(data.opportunity_category === "investment_project" ||
(!data.opportunity_category &&
data.estimated_investment_amount != null)) && (
<>
@@ -365,11 +365,11 @@ export default function OpportunityDraft({
<input
className={control}
value={String(
data.estimated_investment_amount ?? '',
data.estimated_investment_amount ?? "",
)}
onChange={(e) =>
set(
'estimated_investment_amount',
"estimated_investment_amount",
e.target.value,
)
}
@@ -383,17 +383,17 @@ export default function OpportunityDraft({
className={control}
value={String(
data.estimated_investment_currency ??
'',
"",
)}
onChange={(e) =>
set(
'estimated_investment_currency',
"estimated_investment_currency",
e.target.value,
)
}
>
<option value="">Por indicar</option>
{['MZN', 'USD', 'EUR', 'ZAR'].map((c) => (
{["MZN", "USD", "EUR", "ZAR"].map((c) => (
<option key={c}>{c}</option>
))}
</select>
@@ -401,7 +401,7 @@ export default function OpportunityDraft({
</>
)}
{data.opportunity_category &&
data.opportunity_category !== 'investment_project' && (
data.opportunity_category !== "investment_project" && (
<p>
O módulo especializado desta categoria permanece
pendente. Pode guardar a identificação
@@ -437,11 +437,11 @@ export default function OpportunityDraft({
onChange={(e) =>
setConfirm(e.target.checked)
}
/>{' '}
Confirmo a{' '}
/>{" "}
Confirmo a{" "}
{unavailable
? 'restauração'
: 'exclusão lógica/arquivo'}
? "restauração"
: "exclusão lógica/arquivo"}
, preservando histórico e relações
</label>
<button
@@ -450,21 +450,26 @@ export default function OpportunityDraft({
disabled={busy || !confirm}
onClick={() =>
void send(
unavailable ? 'restore' : 'delete',
unavailable ? "restore" : "delete",
)
}
>
{unavailable
? 'Restaurar'
? "Restaurar"
: closedVersion
? 'Arquivar'
: 'Excluir rascunho'}
? "Arquivar"
: "Excluir rascunho"}
</button>
</>
)}
</>
)}
</form>
{record && (
<Link href={`/management/opportunities/${record.id}/resources`}>
Imagens, documentos e localizações
</Link>
)}
{record && (
<Link href={`/management/opportunities/${record.id}/context`}>
Consultar projetos, clusters e relações existentes
@@ -475,7 +480,7 @@ export default function OpportunityDraft({
{history.length ? (
history.map((e) => (
<p key={e.id}>
{e.action} · conta {e.actor_user_id} ·{' '}
{e.action} · conta {e.actor_user_id} ·{" "}
{e.occurred_at}
</p>
))
@@ -1,5 +1,5 @@
import { useForm } from '@inertiajs/react';
import { Management, button } from './shared';
import { useForm } from "@inertiajs/react";
import { Management, button } from "./shared";
export default function Install({
installed,
ready,
@@ -9,6 +9,7 @@ export default function Install({
migration,
backupDate,
rollback,
actionUrl = "/management/opportunity-management-install",
}: {
installed: boolean;
ready: boolean;
@@ -18,13 +19,14 @@ export default function Install({
migration: string;
backupDate: string | null;
rollback: string;
actionUrl?: string;
}) {
const form = useForm({ hash, confirm: false });
return (
<Management title="Ativação da Gestão de Oportunidades">
<p>
Base: {target.database}:{target.port} ·{' '}
{installed ? 'Instalado' : 'Ainda não aplicado'}
Base: {target.database}:{target.port} ·{" "}
{installed ? "Instalado" : "Ainda não aplicado"}
</p>
<ul>
{changes.map((c) => (
@@ -32,17 +34,16 @@ export default function Install({
))}
</ul>
<p>Migração: {migration}</p>
<p>Backup/restauro: {backupDate ?? 'Prova ainda por preparar'}</p>
<p>Backup/restauro: {backupDate ?? "Prova ainda por preparar"}</p>
<p>{rollback}</p>
<p>
Este plano ativa apenas o primeiro incremento; workflow
completo, imagens e módulos especializados permanecem no
backlog.
Este plano ativa apenas as alterações acima; workflow completo e
lacunas restantes permanecem no backlog.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
form.post('/management/opportunity-management-install');
form.post(actionUrl);
}}
>
<label>
@@ -50,9 +51,9 @@ export default function Install({
type="checkbox"
checked={form.data.confirm}
onChange={(e) =>
form.setData('confirm', e.target.checked)
form.setData("confirm", e.target.checked)
}
/>{' '}
/>{" "}
Confirmo a aplicação deste plano
</label>
<button
@@ -0,0 +1,596 @@
import { Link, router } from "@inertiajs/react";
import { useState } from "react";
import {
TerritoryLevel,
type TerritoryOption,
} from "@/components/investment-territory";
import { Management, Field, button, control } from "./shared";
type Payload = Record<string, string | number | boolean | null | object>;
type Resource = {
id: string;
version: number;
deleted_at: string | null;
payload: Payload;
territoryPath?: TerritoryOption[];
file?: {
file_name: string;
malware_scan_status: string;
malware_engine: string | null;
sha256: string;
};
};
type Kind = "locations" | "images" | "documents";
const fields: Record<Kind, string[]> = {
locations: [
"title",
"address",
"operational_unit",
"role_description",
"area_value",
"area_unit",
"allocation_percentage",
"multi_district_rationale",
"source_reference",
"source_date",
],
images: [
"title",
"caption",
"alt_text",
"author_source",
"captured_at",
"rights_valid_until",
"role_description",
"sort_order",
"source_reference",
],
documents: [
"title",
"category",
"issuer",
"edition",
"source_date",
"valid_until",
"source_reference",
"section",
"supported_statement",
],
};
const labels: Record<string, string> = {
title: "Título",
address: "Referência física",
operational_unit: "Unidade operacional",
role_description: "Descrição do papel outro",
area_value: "Área",
area_unit: "Unidade de área",
allocation_percentage: "Distribuição (%)",
multi_district_rationale: "Fundamento multidistrito/interprovincial",
source_reference: "Fonte / referência",
source_date: "Data da fonte",
caption: "Legenda",
alt_text: "Texto alternativo",
author_source: "Autor / fonte",
captured_at: "Data da imagem",
rights_valid_until: "Validade dos direitos",
sort_order: "Ordem",
category: "Categoria documental",
issuer: "Emissor",
edition: "Edição / versão",
valid_until: "Validade",
section: "Página / secção",
supported_statement: "Afirmação fundamentada",
};
const choices: Record<string, string[]> = {
role: [
"headquarters",
"production",
"raw_material",
"storage",
"logistics",
"market",
"support",
"other",
],
imageRole: [
"cover",
"gallery",
"location",
"asset",
"product",
"plan",
"rendering",
"evidence",
"other",
],
territorial_purpose: ["implementation", "context", "impact", "coverage"],
rights_status: ["owned", "licensed", "authorized", "public_domain"],
visibility: ["internal", "authenticated", "public"],
classification: ["public", "internal", "confidential", "restricted"],
};
export default function OpportunityResources({
opportunity,
etag,
resources,
existingLocations,
assessment,
}: {
opportunity: {
id: number;
title: string | null;
status: string;
deleted_at: string | null;
};
etag: string;
resources: Record<Kind, Resource[]>;
existingLocations: { id: string; name: string; location_type: string }[];
assessment: string[];
}) {
const [kind, setKind] = useState<Kind>("locations");
const [editing, setEditing] = useState<Resource | null>(null);
const [payload, setPayload] = useState<Payload>({});
const [path, setPath] = useState<TerritoryOption[]>([]);
const [geometry, setGeometry] = useState("");
const [file, setFile] = useState<File | null>(null);
const [reason, setReason] = useState("");
const [confirm, setConfirm] = useState(false);
const [key, setKey] = useState(() => crypto.randomUUID());
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const set = (name: string, value: Payload[string]) =>
setPayload((previous) => ({ ...previous, [name]: value }));
function select(nextKind: Kind, record: Resource | null = null) {
setKind(nextKind);
setEditing(record);
setPayload(record?.payload ?? {});
setPath(record?.territoryPath ?? []);
setGeometry(
record?.payload.geometry
? JSON.stringify(record.payload.geometry)
: "",
);
setFile(null);
setKey(crypto.randomUUID());
setConfirm(false);
setErrors({});
}
async function execute(action: "save" | "delete" | "restore") {
if (busy) return;
setBusy(true);
setErrors({});
setMessage("");
try {
const form = new FormData();
form.set("etag", etag);
form.set("reason", reason);
form.set("creation_key", key);
if (editing) form.set("version", String(editing.version));
if (action === "save") {
const values = { ...payload };
if (kind === "locations") {
[
"province_id",
"district_id",
"admin_post_id",
"locality_id",
].forEach(
(name, index) =>
(values[name] = path[index]?.id ?? null),
);
values.geometry = geometry ? JSON.parse(geometry) : null;
}
form.set("payload", JSON.stringify(values));
if (file) form.set("file", file);
} else {
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 csrf =
document.cookie
.split("; ")
.find((v) => v.startsWith("XSRF-TOKEN="))
?.split("=")
.slice(1)
.join("=") ?? "";
const result = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"X-XSRF-TOKEN": decodeURIComponent(csrf),
},
body: form,
});
const data = await result.json();
if (!result.ok) {
setErrors(
Object.fromEntries(
Object.entries(data.errors ?? {}).map(
([name, value]) => [
name,
Array.isArray(value)
? value.join(" ")
: String(value),
],
),
),
);
throw new Error(data.message ?? `Falha HTTP ${result.status}`);
}
setMessage(data.message);
setEditing(null);
setPayload({});
setPath([]);
setGeometry("");
setFile(null);
setKey(crypto.randomUUID());
setConfirm(false);
router.reload({
onFinish: () =>
document.getElementById("resource-result")?.focus(),
});
} catch (error) {
setMessage(
error instanceof Error
? error.message
: "Falha de ligação; preenchimento preservado.",
);
} finally {
setBusy(false);
setTimeout(
() => document.getElementById("resource-result")?.focus(),
0,
);
}
}
const unavailable =
!!opportunity.deleted_at || opportunity.status === "archived";
return (
<Management title="Imagens, documentos e localizações">
<Link href={`/management/opportunities/${opportunity.id}/edit`}>
Regressar ao rascunho #{opportunity.id}
</Link>
<p>
Recursos de preparação. Não alteram automaticamente a ficha
pública ou relações territoriais aceites. ClamD real pendente;
scan simulado nunca verifica ficheiros reais.
</p>
<div
id="resource-result"
role="status"
tabIndex={-1}
className="rounded border p-3"
>
{message ||
"Guardar é independente de validar, submeter e publicar."}
{errors.etag && <p>{errors.etag}</p>}
</div>
<section>
<h2>Conferência e pendências</h2>
<ul>
{assessment.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</section>
{existingLocations.length > 0 && (
<section>
<h2>Ligações territoriais existentes preservadas</h2>
{existingLocations.map((r) => (
<p key={r.id}>
{r.name} · {r.location_type}
</p>
))}
</section>
)}
<nav className="flex flex-wrap gap-3">
{(["locations", "images", "documents"] as Kind[]).map((k) => (
<button
className={button}
key={k}
disabled={busy}
onClick={() => select(k)}
>
{k === "locations"
? "Localizações"
: k === "images"
? "Imagens"
: "Documentos"}
</button>
))}
</nav>
<section>
{resources[kind].map((r) => (
<article key={r.id} className="my-3 rounded border p-3">
<p>
{String(
r.payload.title ??
r.file?.file_name ??
"Recurso incompleto",
)}{" "}
· v{r.version} ·{" "}
{r.deleted_at
? "eliminado logicamente"
: "rascunho"}
</p>
{r.file && (
<p>
Scan: {r.file.malware_scan_status} · motor:{" "}
{r.file.malware_engine ?? "não executado"} ·
hash: {r.file.sha256}
</p>
)}
<button
className={button}
onClick={() => select(kind, r)}
>
Ver / editar / restaurar
</button>
{r.file?.malware_scan_status === "clean" &&
!r.deleted_at && (
<a
href={`/management/opportunities/${opportunity.id}/resources/${kind}/${r.id}/download`}
>
Consultar original autorizado
</a>
)}
</article>
))}
</section>
<form
onSubmit={(event) => {
event.preventDefault();
void execute("save");
}}
className="space-y-3"
>
<h2>
{editing
? "Editar recurso / nova versão"
: "Novo recurso incompleto"}
</h2>
{kind === "locations" && (
<div>
{[0, 1, 2, 3].map((index) => (
<TerritoryLevel
key={index}
index={index}
parent={path[index - 1]?.id ?? ""}
selected={path[index]}
onChange={(value) =>
setPath((previous) =>
value
? [
...previous.slice(0, index),
value,
]
: previous.slice(0, index),
)
}
fieldError={
errors[
[
"province_id",
"district_id",
"admin_post_id",
"locality_id",
][index]
]
}
/>
))}
</div>
)}
{kind !== "locations" && (
<Field
label="Original / substituir por nova versão"
error={errors.file}
>
<input
aria-label="Original / substituir por nova versão"
type="file"
accept={
kind === "images"
? ".jpg,.jpeg,.png,.webp"
: ".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
}
onChange={(event) =>
setFile(event.target.files?.[0] ?? null)
}
/>
<p>
Armazenamento privado cifrado, original e versões
preservados; sem acesso até scan limpo.
</p>
</Field>
)}
{fields[kind].map((name) => (
<Field
key={name}
label={labels[name]}
error={errors[name] ?? errors[`payload.${name}`]}
>
<input
aria-label={labels[name]}
className={control}
value={String(payload[name] ?? "")}
type={
name.includes("date") ||
name.endsWith("_at") ||
name.endsWith("_until")
? "date"
: "text"
}
onChange={(event) =>
set(name, event.target.value || null)
}
/>
</Field>
))}
{(kind === "locations"
? ["role", "territorial_purpose"]
: kind === "images"
? ["imageRole", "rights_status", "visibility"]
: ["classification", "visibility"]
).map((name) => (
<Field
key={name}
label={name}
error={
errors[
`payload.${name === "imageRole" ? "role" : name}`
]
}
>
<select
className={control}
aria-label={name}
value={String(
payload[name === "imageRole" ? "role" : name] ??
"",
)}
onChange={(event) =>
set(
name === "imageRole" ? "role" : name,
event.target.value || null,
)
}
>
<option value="">Pendente</option>
{choices[name].map((option) => (
<option key={option}>{option}</option>
))}
</select>
</Field>
))}
{kind === "locations" && (
<Field
label="GeoJSON documentado (opcional)"
error={errors.geometry}
>
<textarea
className={control}
value={geometry}
onChange={(event) =>
setGeometry(event.target.value)
}
/>
<p>
Sem coordenadas continua consultável. Limite oficial
em falta aparece como pendência espacial.
</p>
</Field>
)}
{kind === "images" &&
(["location_id", "rights_document_id"] as const).map(
(name) => (
<Field
key={name}
label={name}
error={errors[`payload.${name}`]}
>
<select
className={control}
value={String(payload[name] ?? "")}
onChange={(event) =>
set(name, event.target.value || null)
}
>
<option value="">Pendente</option>
{resources[
name === "location_id"
? "locations"
: "documents"
]
.filter((r) => !r.deleted_at)
.map((r) => (
<option value={r.id} key={r.id}>
{String(
r.payload.title ??
r.file?.file_name ??
r.id,
)}
</option>
))}
</select>
</Field>
),
)}
{kind !== "documents" && (
<label>
<input
type="checkbox"
checked={
!!payload[
kind === "images"
? "is_cover"
: "is_primary"
]
}
onChange={(event) =>
set(
kind === "images"
? "is_cover"
: "is_primary",
event.target.checked,
)
}
/>
{kind === "images"
? "Selecionar capa de preparação"
: "Localização principal"}
</label>
)}
<Field
label="Motivo (20–1000 caracteres)"
error={errors.reason}
>
<textarea
className={control}
aria-label="Motivo (20–1000 caracteres)"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</Field>
{editing && (
<label>
<input
type="checkbox"
checked={confirm}
onChange={(event) =>
setConfirm(event.target.checked)
}
/>
Confirmar exclusão lógica / restauração
</label>
)}
<div className="flex flex-wrap gap-3">
<button
className={button}
disabled={busy || unavailable || !!editing?.deleted_at}
>
Guardar recurso
</button>
{editing && (
<button
type="button"
className={button}
disabled={busy || unavailable}
onClick={() =>
void execute(
editing.deleted_at ? "restore" : "delete",
)
}
>
{editing.deleted_at
? "Restaurar recurso"
: "Excluir recurso logicamente"}
</button>
)}
</div>
</form>
</Management>
);
}
+8
View File
@@ -24,6 +24,7 @@ use App\Http\Controllers\InvestmentTerritoryController;
use App\Http\Controllers\InvestorWorkspaceController;
use App\Http\Controllers\OpportunityManagementController;
use App\Http\Controllers\OpportunityOriginReviewController;
use App\Http\Controllers\OpportunityResourceController;
use App\Http\Controllers\PortalManagementController;
use App\Http\Controllers\PortalServicesController;
use App\Http\Controllers\PublicBusinessOpportunityController;
@@ -107,6 +108,13 @@ Route::get('/api/v1/investment/territory-options', [PublicGeographyController::c
Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management')->name('management.')->group(function () {
Route::get('opportunity-management-install', [OpportunityManagementController::class, 'installation']);
Route::post('opportunity-management-install', [OpportunityManagementController::class, 'applyInstallation']);
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/{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');
Route::get('opportunities/{id}/resources/{kind}/{resourceId}/download', [OpportunityResourceController::class, 'download'])->whereNumber('id');
Route::get('opportunities', [OpportunityManagementController::class, 'index']);
Route::get('opportunities/create', [OpportunityManagementController::class, 'edit']);
Route::get('opportunities/{id}/edit', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
@@ -0,0 +1,99 @@
<?php
namespace Tests\Feature;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityManagement;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\Support\CleanScannerFixture;
use Tests\TestCase;
class OpportunityResourcesTest extends TestCase
{
use RefreshDatabase;
private function setupDraft(): array
{
Storage::fake('local');
Queue::fake();
$user = User::factory()->create();
config(['portal.managers' => [$user->email]]);
$id = $this->actingAs($user)->postJson('/management/opportunities/drafts', ['creation_key' => (string) Str::uuid(), 'reason' => 'Rascunho de recursos em QA isolada'])->assertCreated()->json('id');
return [$user, Opportunity::findOrFail($id)];
}
private function payload(Opportunity $opportunity, array $payload = []): array
{
return ['etag' => app(OpportunityManagement::class)->etag($opportunity), 'creation_key' => (string) Str::uuid(), 'payload' => $payload, 'reason' => 'Preparação e conferência em QA isolada'];
}
public function test_encrypted_original_scan_pending_recovery_replacement_and_logical_lifecycle(): void
{
[$user,$o] = $this->setupDraft();
$bytes = "%PDF-1.7\nSynthetic QA document\n%%EOF";
$result = $this->post('/management/opportunities/'.$o->id.'/resources/documents', [...$this->payload($o, ['title' => 'QA source', 'classification' => 'confidential']), 'file' => UploadedFile::fake()->createWithContent('qa.pdf', $bytes)], ['Accept' => 'application/json'])->assertCreated()->json();
$r = DB::table('opportunity_documents')->where('id', $result['id'])->first();
$d = DB::table('documents')->where('id', $r->document_id)->first();
$this->assertSame('pending', $d->malware_scan_status);
$this->assertSame($bytes, Crypt::decryptString(Storage::disk('local')->get($d->storage_key)));
$this->assertNotSame($bytes, Storage::disk('local')->get($d->storage_key));
Queue::assertPushed(ScanDocumentForMalware::class);
$this->get('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/download')->assertStatus(423);
$this->get('/management/opportunities/'.$o->id.'/resources')->assertOk();
// Only a labelled QA fixture scans synthetic bytes; never evidence for an active file.
(new ScanDocumentForMalware($d->id))->handle(new CleanScannerFixture);
$this->assertDatabaseHas('documents', ['id' => $d->id, 'malware_engine' => 'QA-Test-Double', 'malware_scan_status' => 'clean']);
$this->get('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/download')->assertOk()->assertContent($bytes);
$replacement = [...$this->payload($o->fresh(), ['title' => 'QA changed', 'edition' => '2']), 'version' => 1, 'file' => UploadedFile::fake()->createWithContent('qa-v2.pdf', "%PDF-1.7\nQA second version\n%%EOF")];
$this->post('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id, $replacement, ['Accept' => 'application/json'])->assertOk();
$current = DB::table('opportunity_documents')->where('id', $r->id)->first();
$this->assertNotSame($d->id, $current->document_id);
$this->assertDatabaseHas('documents', ['id' => $d->id, 'deleted_at' => null]);
$this->assertTrue(Storage::disk('local')->exists($d->storage_key));
$this->post('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id, $replacement, ['Accept' => 'application/json'])->assertConflict();
$life = ['etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 2, 'action' => 'delete', 'confirm' => true, 'reason' => 'Exclusão lógica de anexo apenas QA'];
$this->postJson('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/lifecycle', $life)->assertOk();
$this->assertSoftDeleted('opportunity_documents', ['id' => $r->id]);
$this->postJson('/management/opportunities/'.$o->id.'/resources/documents/'.$r->id.'/lifecycle', [...$life, 'etag' => app(OpportunityManagement::class)->etag($o->fresh()), 'version' => 3, 'action' => 'restore'])->assertOk();
$this->assertDatabaseHas('opportunity_documents', ['id' => $r->id, 'deleted_at' => null, 'version' => 4]);
$this->assertSame(4, DB::table('audit_events')->where('source_system', 'opportunity-resources')->count());
$this->actingAs(User::factory()->create())->get('/management/opportunities/'.$o->id.'/resources')->assertForbidden();
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), ['title' => 'Denied']))->assertForbidden();
}
public function test_multiple_locations_without_coordinates_and_invalid_hierarchy_and_geometry(): void
{
[$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']]);
$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();
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), [...$p, 'role' => 'market', 'is_primary' => false, 'allocation_percentage' => 40]))->assertCreated();
$this->assertSame(2, DB::table('opportunity_location_drafts')->count());
$this->assertSame(0, DB::table('opportunity_locations')->count());
$this->get('/management/opportunities/'.$o->id.'/resources')->assertOk();
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), ['title' => 'Bad geo', 'geometry' => ['type' => 'Point', 'coordinates' => [300, 100]]]))->assertUnprocessable()->assertJsonValidationErrors('geometry');
$this->postJson('/management/opportunities/'.$o->id.'/resources/locations', $this->payload($o->fresh(), ['title' => 'Gap', 'district_id' => $district]))->assertUnprocessable()->assertJsonValidationErrors('district_id');
$this->assertDatabaseHas('opportunity_location_drafts', ['id' => $id, 'deleted_at' => null]);
}
public function test_invalid_upload_and_cross_opportunity_links_do_not_create_resources(): void
{
[$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->actingAs(User::factory()->create())->get('/management/opportunity-resources-install')->assertForbidden();
}
}
@@ -9,7 +9,22 @@ $installed = (bool) $pdo->query("SELECT EXISTS(SELECT 1 FROM information_schema.
$migrationApplied = (int) $pdo->query("SELECT count(*) FROM migrations WHERE migration='2026_10_09_020000_extend_opportunity_management'")->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 = [];
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') {
$columns = $pdo->query("SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name='opportunities' ORDER BY ordinal_position")->fetchAll(PDO::FETCH_COLUMN);
$columns = array_diff($columns, ['management_version', 'opportunity_category', 'management_draft', 'management_creation_key', 'first_submitted_at', 'management_closed_once']);
$from = '(SELECT '.implode(',', array_map(fn ($column) => '"'.$column.'"', $columns)).' FROM opportunities)';
} else {
$from = $table === 'audit_events' ? "(SELECT * FROM audit_events WHERE source_system IS DISTINCT FROM 'opportunity-management-install')" : $key;
}
$rows = $pdo->query('SELECT row_to_json(t)::text FROM '.$from.' t ORDER BY row_to_json(t)::text')->fetchAll(PDO::FETCH_COLUMN);
$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, 'active_writes' => 0];
$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];
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;
+92
View File
@@ -0,0 +1,92 @@
<?php
use App\Models\User;
use App\Services\OpportunityResourceInstallation;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Vite;
$root = dirname(__DIR__);
$dir = $root.'/storage/app/private/investmoz-v2';
$backup = json_decode(file_get_contents($dir.'/opportunity-resources-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
$target = $backup['restored_target'];
if (! $backup['restore_verified'] || ! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441) {
throw new RuntimeException('QA target required');
}
foreach (['APP_ENV' => 'testing', 'APP_DEBUG' => 'true', 'APP_URL' => 'http://127.0.0.1:18096', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'SESSION_DRIVER' => 'file', 'SESSION_COOKIE' => 'opportunity_resources_qa', 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
putenv($key.'='.$value);
$_ENV[$key] = $_SERVER[$key] = $value;
}
require $root.'/vendor/autoload.php';
$app = require $root.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
if ($identity->database !== $target['database'] || (int) $identity->port !== 55441) {
throw new RuntimeException('Refusing non-QA target');
}
config(['portal.managers' => ['opportunity-manager@example.invalid'], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$sessions = $dir.'/qa-opportunity-resources-sessions';
if (! is_dir($sessions)) {
mkdir($sessions, 0700, true);
}
config(['session.files' => $sessions]);
Vite::useHotFile($root.'/storage/framework/qa-opportunity-resources-hot-absent');
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'prepare') {
$migration = require $root.'/database/migrations/'.OpportunityResourceInstallation::MIGRATION.'.php';
$migration->up();
$oldColumns = array_keys((array) DB::selectOne('SELECT * FROM opportunities LIMIT 1'));
$oldColumns = array_diff($oldColumns, []);
$projection = implode(',', array_map(fn ($column) => '"'.$column.'"', $oldColumns));
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM (SELECT '.$projection.' FROM opportunities) t ORDER BY row_to_json(t)::text');
if (hash('sha256', implode("\n", array_column($rows, 'row'))) !== $backup['table_hashes']['"public"."opportunities"']) {
throw new RuntimeException('Existing opportunity values changed');
}
$migration->down();
foreach ($backup['table_hashes'] as $table => $hash) {
$rows = DB::select('SELECT row_to_json(t)::text AS row FROM '.$table.' t ORDER BY row_to_json(t)::text');
if (hash('sha256', implode("\n", array_column($rows, 'row'))) !== $hash) {
throw new RuntimeException('Restore/rollback comparison failed: '.$table);
}
}
Artisan::call('migrate', ['--path' => 'database/migrations/'.OpportunityResourceInstallation::MIGRATION.'.php', '--force' => true]);
$user = User::updateOrCreate(['email' => 'opportunity-manager@example.invalid'], ['name' => 'QA opportunity manager', 'password' => Hash::make('Opportunity-QA-2026!')]);
$user->forceFill(['email_verified_at' => now()])->save();
$proof = ['target' => $target, 'code_hash' => app(OpportunityResourceInstallation::class)->fingerprint(), 'compatibility_verified' => true, 'reversal_verified' => true, 'tables_compared' => count($backup['table_hashes']), 'active_writes' => 0];
file_put_contents($dir.'/opportunity-resources-compatibility.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof).PHP_EOL;
exit;
}
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) {
$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);
$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]) {
throw new RuntimeException('Baseline data changed: '.$table);
}
}
$browser = json_decode(file_get_contents($dir.'/opportunity-resources-browser.json'), true, 512, JSON_THROW_ON_ERROR);
$draft = DB::table('opportunities')->where('id', $browser['id'])->first();
if ($draft->status !== 'draft' || $draft->deleted_at !== null) {
throw new RuntimeException('Browser cycle not restored');
}
$proof = ['target' => $target, 'code_hash' => app(OpportunityResourceInstallation::class)->fingerprint(), 'preserved' => $checks, 'browser_draft_id' => $draft->id, 'browser_draft_restored' => true, 'origin_classifications' => DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->count(), 'active_writes' => 0];
file_put_contents($dir.'/opportunity-resources-preservation.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode($proof).PHP_EOL;
exit;
}
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
$asset = realpath($root.'/public'.$path);
$public = realpath($root.'/public');
if ($path !== '/' && $asset && $public && str_starts_with($asset, $public.DIRECTORY_SEPARATOR) && is_file($asset)) {
return false;
}
header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
$app->handleRequest(Request::capture());