@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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])]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user