Compare commits

..
1541 changed files with 25705 additions and 355736 deletions
-5
View File
@@ -119,8 +119,3 @@ INVESTMOZ_TAX_RULE_APPROVERS=
INVESTMOZ_TAX_RULE_PUBLISHERS= INVESTMOZ_TAX_RULE_PUBLISHERS=
INVESTMOZ_ZONE_TERRITORY_REVIEWERS= INVESTMOZ_ZONE_TERRITORY_REVIEWERS=
# Local LibreTranslate service and installed language models.
LIBRETRANSLATE_URL=http://127.0.0.1:5000
LIBRETRANSLATE_TIMEOUT=60
LIBRETRANSLATE_API_KEY=
-17
View File
@@ -1,22 +1,5 @@
## Uniformização pública e menu — decisão expressa do proprietário,11/11/2026
Lista e detalhe das instituições financeiras devem usar a mesma apresentação:descrição única,categoria/tipo sem repetição,produtos/serviços,contactos e moradas úteis apenas quando disponíveis. Não apresentar secções vazias,avisos genéricos repetidos,notas de publicação/importação,preços antigos por reconfirmar ou a mesma informação várias vezes. Deduplicar apenas a apresentação,conservando dados e fontes no editor. Usar Quorus e Moza Banco como exemplos de conferência.
Menu principal:Início,Descoberta,Realização,Suporte,Sobre nós,Mais. Descoberta:Concursos e anúncios,Localizações,Instituições financeiras,Catálogos relacionados. Realização:Iniciar investimento,Licenciamento,Acesso à terra. Suporte:Biblioteca,Legislação,Serviços ao investidor,Acompanhamento do projecto,Apoio ao investidor. Preservar conteúdo deSobre nós/Mais,contexto dos dossiers e permissões. Escolha de apresentação:acessos de trabalho antes no nível principal passam paraMais,sem novas concessões.
## Comunicação pública — orientação expressa do proprietário,11/11/2026
Apresentar conteúdos públicos com linguagem profissional,clara e apelativa,centrada na entidade,oportunidade,produtos e serviços úteis ao investidor. Não usar descrições de importação,cadastro,fichas,ficheiros ou documentos geradores como apresentação comercial. Preservar as fontes,versões e rastreabilidade no cadastro/editor;ligações institucionais e datas úteis podem ser consultadas separadamente. Não inventar ofertas,condições,disponibilidade,cobertura ou garantias para preencher lacunas. Esta orientação aplica-se transversalmente aos conteúdos do portal.
## Simulação de viabilidade — clarificação expressa do proprietário,11/11/2026
O InvesteMoz simula impostos para avaliar a viabilidade do negócio; não actua como entidade que executa pagamentos. Apresentar PPC/PEC e distribuição por meses como estimativas fiscais e projecções de tesouraria,com fontes e pressupostos. Montantes já pagos,quando fornecidos,são dados documentais para o cálculo. Não confundir uma hipótese de caixa com pagamento efectuado,declaração submetida ou cumprimento fiscal certificado. Preservar os cálculos,históricos e dados aceites; não criar serviços de cobrança/execução de pagamentos como requisito de conclusão deste simulador.
# Instruções permanentes do projeto InvesteMoz # Instruções permanentes do projeto InvesteMoz
## Encerramento e reabertura do dossier — decisão do proprietário, 03/11/2026
O proprietário definiu expressamente: «o dossier tem um dono, esse é que deve executar essa acção», em resposta a quem pode encerrar e reabrir o dossier completo. Usar o dono registado no dossier e conservar a verificação do acesso efectivo existente, incluindo a representação actual quando pertence a uma organização. A condição de gestor, analista ou outro representante não substitui por inferência esse dono. Registar a decisão, motivo, versão e histórico. Encerrar ou reabrir o dossier não equivale a decidir/aprovar pedidos, conferir pagamentos ou alterar em cascata estados de documentos e tarefas.
## Percurso adaptativo — decisão expressa da Tempo, 23/10/2026 ## Percurso adaptativo — decisão expressa da Tempo, 23/10/2026
Carteira/projectos âncora e percurso adaptativo têm diagnósticos distintos, mas fazem parte da mesma experiência e responsabilidade de correcção. Separar o diagnóstico não autoriza adiamento, desligamento do percurso ou perda de contexto entre corredor/localização, oportunidades, associações documentadas a âncoras/clusters, dossier e simulador. A reposição prioritária só se considera demonstrada com causa delimitada e disponibilidade recuperada, reutilizando o inventário e os recibos existentes; melhorias noutro ponto não a comprovam. Consultar os registos de reposição antes de voltar a declarar causa desconhecida ou registos desaparecidos. Carteira/projectos âncora e percurso adaptativo têm diagnósticos distintos, mas fazem parte da mesma experiência e responsabilidade de correcção. Separar o diagnóstico não autoriza adiamento, desligamento do percurso ou perda de contexto entre corredor/localização, oportunidades, associações documentadas a âncoras/clusters, dossier e simulador. A reposição prioritária só se considera demonstrada com causa delimitada e disponibilidade recuperada, reutilizando o inventário e os recibos existentes; melhorias noutro ponto não a comprovam. Consultar os registos de reposição antes de voltar a declarar causa desconhecida ou registos desaparecidos.
@@ -1,25 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\InvestmentOpportunityAlerts;
use Illuminate\Console\Command;
final class DispatchInvestmentAlerts extends Command
{
protected $signature = 'investmoz:dispatch-opportunity-alerts';
protected $description = 'Generate due opt-in alerts from public catalogues, without external messages';
public function handle(InvestmentOpportunityAlerts $alerts): int
{
if (! $alerts->installed()) {
$this->warn('Opportunity alerts schema is not installed.');
return self::SUCCESS;
}
$this->info('Matched public catalogue items: '.$alerts->dispatchDue());
return self::SUCCESS;
}
}
@@ -1,37 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Opportunity;
use App\Services\OfferAvailability;
use App\Services\PrivateOfferOperations;
use Illuminate\Console\Command;
final class ExpirePrivateOffers extends Command
{
protected $signature = 'investmoz:offers-expire {--confirm : Persist the already effective expiry}';
protected $description = 'Conferir expiração de Ofertas sem publicar conteúdos ou alterar o NDA';
public function handle(): int
{
$count = 0;
Opportunity::query()->whereNotNull('workflow_context->private_offer_preparation')->orderBy('id')->chunkById(100, function ($records) use (&$count) {
foreach ($records as $record) {
if (app(OfferAvailability::class)->record($record)['state'] !== 'expired') {
continue;
}
if ($this->option('confirm')) {
if (app(PrivateOfferOperations::class)->expire($record->id)) {
$count++;
}
} else {
$count++;
}
}
});
$this->info(($this->option('confirm') ? 'Expirações registadas: ' : 'Expiradas, sem escrita: ').$count);
return self::SUCCESS;
}
}
+4 -9
View File
@@ -4,7 +4,6 @@ namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware; use App\Jobs\ScanDocumentForMalware;
use App\Models\User; use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -54,9 +53,7 @@ class ImportDocumentBatch extends Command
]; ];
private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8'; private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8';
private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa'; private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa';
private const TRIO_HASHES = [ private const TRIO_HASHES = [
'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941', 'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941',
'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5', 'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5',
@@ -279,9 +276,7 @@ class ImportDocumentBatch extends Command
return $failures > 0 ? self::FAILURE : self::SUCCESS; return $failures > 0 ? self::FAILURE : self::SUCCESS;
} }
/** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate. /** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate. */
* @param array<array-key, mixed> $manifest
*/
private function validatePilot(string $manifestPath, string $manifestBytes, array $manifest, string $staging, bool $apply, mixed $actorId, bool $sectors = false, bool $trio = false): void private function validatePilot(string $manifestPath, string $manifestBytes, array $manifest, string $staging, bool $apply, mixed $actorId, bool $sectors = false, bool $trio = false): void
{ {
$allowed = $trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES); $allowed = $trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES);
@@ -315,7 +310,7 @@ class ImportDocumentBatch extends Command
->where('d.rights_status', 'authorized')->where('r.rights_status', 'authorized')->where('r.published', true) ->where('d.rights_status', 'authorized')->where('r.rights_status', 'authorized')->where('r.published', true)
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->get(['d.storage_key']); ->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')->get(['d.storage_key']);
if ($images->count() !== 1 || ! Storage::disk('local')->exists($images[0]->storage_key) if ($images->count() !== 1 || ! Storage::disk('local')->exists($images[0]->storage_key)
|| ! hash_equals(self::TRIO_REUSED_IMAGE_SHA256, CheckedFile::sha256(Storage::disk('local')->path($images[0]->storage_key)))) { || ! hash_equals(self::TRIO_REUSED_IMAGE_SHA256, hash_file('sha256', Storage::disk('local')->path($images[0]->storage_key)))) {
throw new RuntimeException('The shared image must already exist once, published, authorized and clean with the exact original bytes; no image is imported.'); throw new RuntimeException('The shared image must already exist once, published, authorized and clean with the exact original bytes; no image is imported.');
} }
} }
@@ -349,8 +344,8 @@ class ImportDocumentBatch extends Command
|| ! str_ends_with($proof['restore_target']['database'] ?? '', '_test') || (int) ($proof['restore_target']['port'] ?? 0) !== 55441 || ! str_ends_with($proof['restore_target']['database'] ?? '', '_test') || (int) ($proof['restore_target']['port'] ?? 0) !== 55441
|| $verified === false || $verified > time() || $verified < time() - 86400 || $verified === false || $verified > time() || $verified < time() - 86400
|| ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '') || ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '')
|| ! hash_equals($proof['sha256'] ?? '', CheckedFile::sha256($proof['dump_path'])) || ! hash_equals($proof['sha256'] ?? '', hash_file('sha256', $proof['dump_path']))
|| ! hash_equals($proof['files_sha256'] ?? '', CheckedFile::sha256($proof['files_path']))) { || ! hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path']))) {
throw new RuntimeException('Backup/restoration identity, freshness or integrity does not match the active pilot.'); throw new RuntimeException('Backup/restoration identity, freshness or integrity does not match the active pilot.');
} }
} }
@@ -7,7 +7,6 @@ use App\Models\User;
use App\Services\SimulationTaxParameterResolver; use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression; use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance; use App\Services\SimulationTaxRuleGovernance;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase; use App\Support\IsolatedQaDatabase;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@@ -25,8 +24,8 @@ class RegisterFiscalQaDraft extends Command
$source = InvestmentLegalSource::findOrFail($this->argument('source')); $source = InvestmentLegalSource::findOrFail($this->argument('source'));
abort_unless($source->validation_status === 'validated' && $source->diploma === 'Lei 22/2022' abort_unless($source->validation_status === 'validated' && $source->diploma === 'Lei 22/2022'
&& $source->article === 'Artigo 17(1)', 422, 'Seleccione a referência aceite da taxa geral, Lei 22/2022, artigo 17(1).'); && $source->article === 'Artigo 17(1)', 422, 'Seleccione a referência aceite da taxa geral, Lei 22/2022, artigo 17(1).');
$payload = json_decode(CheckedFile::read(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR); $payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$actualHash = CheckedFile::sha256(base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf')); $actualHash = hash_file('sha256', base_path('docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf'));
abort_unless(hash_equals($payload['source_sha256'], $actualHash), 422, 'O exemplar não corresponde ao hash da ficha.'); abort_unless(hash_equals($payload['source_sha256'], $actualHash), 422, 'O exemplar não corresponde ao hash da ficha.');
$payload['legal_source_id'] = (string) $source->id; $payload['legal_source_id'] = (string) $source->id;
app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']); app(SimulationTaxRuleExpression::class)->validate($payload['rule_definition']);
@@ -1,21 +0,0 @@
<?php
namespace App\Exceptions;
use Illuminate\Http\JsonResponse;
use RuntimeException;
use Throwable;
final class OpportunityWorkflowUnavailable extends RuntimeException
{
public function __construct(public readonly string $reference, public readonly bool $recorded, Throwable $previous)
{
parent::__construct('Opportunity operation result requires verification.', 0, $previous);
}
public function render(): JsonResponse
{
return response()->json(['message' => 'Não foi possível confirmar o resultado. Consulte o estado da oportunidade e dos recursos antes de repetir. '.($this->recorded ? 'Incidente técnico registado. ' : 'Não foi possível guardar o incidente; conserve esta referência. ').'Referência: '.$this->reference,
'reference' => $this->reference, 'incident_recorded' => $this->recorded], 503)->header('Cache-Control', 'private, no-store');
}
}
@@ -1,83 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Throwable;
class TranslationApiController extends Controller
{
public function languages(): JsonResponse
{
try {
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->get($this->url().'/languages');
if ($response->successful() && is_array($response->json())) {
return response()->json($response->json())->header('Cache-Control', 'no-store');
}
} catch (Throwable) {
}
return response()->json(['message' => 'Serviço de tradução temporariamente indisponível.'], 503);
}
public function translate(Request $request): JsonResponse
{
$data = $request->validate([
'text' => is_array($request->input('text'))
? 'required|array|min:1|max:8'
: 'required|string|max:10000',
'text.*' => 'required|string|max:10000',
'source_lang' => 'required|string|max:20',
'target_lang' => 'required|string|max:20',
'is_html' => 'boolean',
]);
$isBatch = is_array($data['text']);
if ($isBatch && mb_strlen(implode('', $data['text'])) > 10000) {
return response()->json(['message' => 'O lote de tradução excede o limite permitido.'], 422);
}
if ($data['source_lang'] === $data['target_lang']) {
return response()->json([
'original_text' => $data['text'],
'translated_text' => $data['text'],
'source_lang' => $data['source_lang'],
'target_lang' => $data['target_lang'],
])->header('Cache-Control', 'no-store');
}
$payload = ['q' => $data['text'], 'source' => $data['source_lang'], 'target' => $data['target_lang'], 'format' => ($data['is_html'] ?? false) ? 'html' : 'text'];
if (config('services.translation.api_key')) {
$payload['api_key'] = config('services.translation.api_key');
}
try {
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->post($this->url().'/translate', $payload);
$translated = $response->json('translatedText');
$validTranslation = $isBatch
? is_array($translated)
&& count($translated) === count($data['text'])
&& ! array_filter($translated, fn ($text) => ! is_string($text) || $text === '')
: is_string($translated) && $translated !== '';
if ($response->successful() && $validTranslation) {
return response()->json([
'original_text' => $data['text'],
'translated_text' => $translated,
'source_lang' => $data['source_lang'],
'target_lang' => $data['target_lang'],
])->header('Cache-Control', 'no-store');
}
} catch (Throwable) {
}
return response()->json(['message' => 'Serviço de tradução temporariamente indisponível.'], 503)->header('Cache-Control', 'no-store');
}
private function url(): string
{
return rtrim((string) config('services.translation.url'), '/');
}
}
@@ -4,8 +4,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\InvestmentGeography; use App\Models\Sector;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -21,9 +20,8 @@ class HomeController extends Controller
$locale = $validated['locale'] ?? 'pt'; $locale = $validated['locale'] ?? 'pt';
$data = Cache::remember("portal_home_territorial_v2_{$locale}", now()->addMinutes(10), function () { $data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () {
$sectors = SectorOrganization::catalog(); $sectors = \App\Services\SectorOrganization::catalog();
$statistics = app(InvestmentGeography::class)->opportunityStatistics();
$featured = Opportunity::with('sector:id,name,slug') $featured = Opportunity::with('sector:id,name,slug')
->published()->where('is_featured', true) ->published()->where('is_featured', true)
@@ -50,9 +48,9 @@ class HomeController extends Controller
'url' => "/oportunidades/{$o->slug}", 'url' => "/oportunidades/{$o->slug}",
])->values()->all(), ])->values()->all(),
'stats' => [ 'stats' => [
'opportunities' => $statistics['opportunities'], 'opportunities' => Opportunity::published()->count(),
'sectors' => $sectors->count(), 'sectors' => $sectors->count(),
'provinces' => $statistics['provinces'], 'provinces' => Opportunity::published()->distinct('province')->count('province'),
], ],
]; ];
}); });
@@ -8,13 +8,11 @@ use App\Http\Requests\StoreInterestRequestRequest;
use App\Jobs\NotifyInterestReceived; use App\Jobs\NotifyInterestReceived;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\InterestRequest; use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\IdempotencyConflict; use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission; use App\Services\InterestSubmission;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -28,7 +26,7 @@ class InterestRequestController extends Controller
$validated = [...$request->validated(), '_actor_id' => $request->user()->id]; $validated = [...$request->validated(), '_actor_id' => $request->user()->id];
$key = $request->header('Idempotency-Key'); $key = $request->header('Idempotency-Key');
abort_unless(is_string($key), 422, 'Identificador do envio em falta.'); abort_unless(is_string($key), 422, 'Identificador do envio em falta.');
Validator::make(['idempotency_key' => $key], ['idempotency_key' => ['required', 'uuid']])->validate(); Validator::make(['idempotency_key' => $key], ['idempotency_key' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate();
try { try {
$interestRequest = $submission->receive($validated, $key, function () use ($request, $validated) { $interestRequest = $submission->receive($validated, $key, function () use ($request, $validated) {
@@ -80,20 +78,19 @@ class InterestRequestController extends Controller
} }
} }
$row = InvestmentSubmission::create([ $row = \App\Models\InvestmentSubmission::create([
'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id, 'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id,
'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1, 'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1,
'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity->title ?? $business?->public_title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'], 'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity?->title ?? $business?->title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'],
'objective' => $validated['message'], 'sector' => $validated['sectorInterest'], 'objective' => $validated['message'], 'sector' => $validated['sectorInterest'],
'country' => $validated['country'], 'existing_business_id' => $business?->id, 'country' => $validated['country'], 'existing_business_id' => $business?->id,
'modality' => $validated['modality'] ?? null], 'modality' => $validated['modality'] ?? null],
]); ]);
DB::table('investment_submission_events')->insert([ \Illuminate\Support\Facades\DB::table('investment_submission_events')->insert([
'submission_id' => $row->id, 'actor_id' => $request->user()->id, 'submission_id' => $row->id, 'actor_id' => $request->user()->id,
'action' => 'submitted', 'version' => 1, 'created_at' => now(), 'action' => 'submitted', 'version' => 1, 'created_at' => now(),
]); ]);
$interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save(); $interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save();
return $interest; return $interest;
}); });
@@ -102,8 +99,7 @@ class InterestRequestController extends Controller
'message' => 'Manifestação de interesse registada com sucesso.', 'message' => 'Manifestação de interesse registada com sucesso.',
'data' => [ 'data' => [
'reference' => $interestRequest->reference, 'reference' => $interestRequest->reference,
// The POST acknowledgement is stable on replay; current workflow state is in tracking. 'status' => $interestRequest->status,
'status' => InterestStatus::Received,
'tracking_url' => '/investidor/manifestacoes', 'tracking_url' => '/investidor/manifestacoes',
], ],
], 201); ], 201);
@@ -4,11 +4,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityTextSearch;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -20,11 +15,6 @@ class OpportunityController extends Controller
'q' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
'sector' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:80'],
'province' => ['nullable', 'string', 'max:80'], 'province' => ['nullable', 'string', 'max:80'],
'province_id' => ['nullable', 'uuid'],
'district_id' => ['nullable', 'uuid'],
'admin_post_id' => ['nullable', 'uuid'],
'locality_id' => ['nullable', 'uuid'],
'geo_location_id' => ['nullable', 'uuid'],
'maturity_level' => ['nullable', 'integer', 'between:1,6'], 'maturity_level' => ['nullable', 'integer', 'between:1,6'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'], 'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
@@ -43,30 +33,36 @@ class OpportunityController extends Controller
} }
if (! empty($validated['q'])) { if (! empty($validated['q'])) {
app(OpportunityTextSearch::class)->constrain($query, $validated['q']); $term = $validated['q'];
$query->where(fn ($q) => $q
->whereLike('title', "%{$term}%")
->orWhereLike('description', "%{$term}%")
->orWhereLike('summary', "%{$term}%")
->orWhereLike('reference', "%{$term}%")
->orWhereLike('province', "%{$term}%")
->orWhereHas('sector', fn ($sectorQuery) => $sectorQuery
->whereLike('name', "%{$term}%")
->orWhereLike('slug', "%{$term}%")));
} }
if (! empty($validated['sector'])) { if (! empty($validated['sector'])) {
$sector = $validated['sector']; $sector = $validated['sector'];
SectorOrganization::constrain($query, $sector); \App\Services\SectorOrganization::constrain($query, $sector);
} }
$territory = array_intersect_key($validated, array_flip(['province', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'geo_location_id'])); if (! empty($validated['province'])) {
if (array_filter($territory, fn ($value) => $value !== null && $value !== '') !== []) { $query->where('province', $validated['province']);
// Keep the legacy search's sorting and non-territorial filters while sharing the public territorial contract.
$query->whereIn('opportunities.id', app(InvestmentCatalogue::class)->query($territory)->reorder()->select('opportunities.id'));
} }
if (! empty($validated['maturity_level'])) { if (! empty($validated['maturity_level'])) {
OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']); \App\Services\OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
} }
$results = $query->orderByDesc('is_featured') $results = $query->orderByDesc('is_featured')
->orderByDesc('published_at') ->orderByDesc('published_at')
->paginate($validated['per_page'] ?? 12); ->paginate($validated['per_page'] ?? 12);
app(PublicSearchMetrics::class)->candidate($request, 'opportunities_api', $results->total(), $validated);
return response()->json($results->withQueryString()->through(fn ($record) => app(InvestmentCatalogue::class)->presentLocation($record))); return response()->json($results);
} }
public function show(string $slug): JsonResponse public function show(string $slug): JsonResponse
@@ -3,10 +3,7 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Sector;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\EconomicReferenceCatalog;
use App\Services\SectorOrganization;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -47,9 +44,7 @@ class ReferenceCatalogController extends Controller
$cae = $catalog === 'economic-activities' && ($data['origin'] ?? 'portal') === 'cae'; $cae = $catalog === 'economic-activities' && ($data['origin'] ?? 'portal') === 'cae';
$versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $cae ? 'economic-activities-cae' : $catalog)->where('locale', 'pt-MZ'); $versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $cae ? 'economic-activities-cae' : $catalog)->where('locale', 'pt-MZ');
if ($cae && ! app(EconomicReferenceCatalog::class)->available()) { if ($cae && ! app(\App\Services\EconomicReferenceCatalog::class)->available()) { $versionQuery->whereRaw('false'); }
$versionQuery->whereRaw('false');
}
if (isset($data['version'])) { if (isset($data['version'])) {
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']); $versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
} else { } else {
@@ -87,7 +82,7 @@ class ReferenceCatalogController extends Controller
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString(); $page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString();
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, (array) $row))->values(); $items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, (array) $row))->values();
if ($cae) { if ($cae) {
$paths = app(EconomicReferenceCatalog::class)->activities()->keyBy('id'); $paths = app(\App\Services\EconomicReferenceCatalog::class)->activities()->keyBy('id');
$items = $items->map(fn ($row) => [...$row, 'level' => $paths[$row['id']]->level, 'path' => $paths[$row['id']]->path, 'source_version' => $paths[$row['id']]->source_version, 'edition' => 'not_determined', 'divergence' => '600/579 documentalmente aberta']); $items = $items->map(fn ($row) => [...$row, 'level' => $paths[$row['id']]->level, 'path' => $paths[$row['id']]->path, 'source_version' => $paths[$row['id']]->source_version, 'edition' => 'not_determined', 'divergence' => '600/579 documentalmente aberta']);
} }
@@ -174,17 +169,15 @@ class ReferenceCatalogController extends Controller
->where('valid_from', '<=', today()->toDateString()) ->where('valid_from', '<=', today()->toDateString())
->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString())) ->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()))
->select(['id', 'code', 'label_pt_mz as name', 'parent_id', 'sector_id', 'version_no']); ->select(['id', 'code', 'label_pt_mz as name', 'parent_id', 'sector_id', 'version_no']);
if (empty($data['version'])) { if (empty($data['version'])) $query->where('id', '!=', \App\Services\SectorOrganization::LEGACY_ACTIVITY_ID);
$query->where('id', '!=', SectorOrganization::LEGACY_ACTIVITY_ID);
}
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) { if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) {
$query->where('parent_id', $data['parent_id']); $query->where('parent_id', $data['parent_id']);
} else { } else {
$query->whereNull('parent_id'); $query->whereNull('parent_id');
} }
if (! empty($data['sector_id'])) { if (! empty($data['sector_id'])) {
$sector = Sector::whereKey($data['sector_id'])->first(); $sector = \App\Models\Sector::find($data['sector_id']);
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} }
if (! empty($data['q'])) { if (! empty($data['q'])) {
$this->search($query, $data['q'], ['code', 'label_pt_mz']); $this->search($query, $data['q'], ['code', 'label_pt_mz']);
@@ -4,14 +4,13 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Sector; use App\Models\Sector;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
class SectorController extends Controller class SectorController extends Controller
{ {
public function index(): JsonResponse public function index(): JsonResponse
{ {
$sectors = SectorOrganization::catalog()->map(fn ($s) => [ $sectors = \App\Services\SectorOrganization::catalog()->map(fn ($s) => [
'id' => $s->id, 'name' => $s->name, 'slug' => $s->slug, 'id' => $s->id, 'name' => $s->name, 'slug' => $s->slug,
'shortDescription' => $s->short_description, 'description' => $s->description, 'shortDescription' => $s->short_description, 'description' => $s->description,
'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count, 'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count,
@@ -26,8 +25,7 @@ class SectorController extends Controller
$sector = Sector::with(['opportunities' => fn ($q) => $q->published()]) $sector = Sector::with(['opportunities' => fn ($q) => $q->published()])
->where('slug', $slug)->firstOrFail(); ->where('slug', $slug)->firstOrFail();
$sector->setRelation('opportunities', SectorOrganization::opportunities($sector)->get()); $sector->setRelation('opportunities', \App\Services\SectorOrganization::opportunities($sector)->get());
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => \App\Services\SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
} }
} }
@@ -5,17 +5,14 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\ScanDocumentForMalware; use App\Jobs\ScanDocumentForMalware;
use App\Services\SimulationFormDrafts; use App\Services\SimulationFormDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use stdClass;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class SimulationOfferDocumentController extends Controller class SimulationOfferDocumentController extends Controller
{ {
public function store(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse public function store(Request $request, string $id, SimulationFormDrafts $drafts)
{ {
abort_unless(Str::isUuid($id), 404); abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request); $drafts->find($id, $request);
@@ -23,7 +20,6 @@ class SimulationOfferDocumentController extends Controller
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,image/png,image/jpeg', 'max:10240']]); $request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,image/png,image/jpeg', 'max:10240']]);
$file = $request->file('file'); $file = $request->file('file');
$hash = hash_file('sha256', $file->getRealPath()); $hash = hash_file('sha256', $file->getRealPath());
abort_unless(is_string($hash), 422, 'Não foi possível verificar o conteúdo do anexo.');
$existing = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->where('sha256', $hash)->whereNull('deleted_at')->first(); $existing = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) { if ($existing) {
return response()->json(['data' => $this->metadata($id, $existing)], 200)->header('Cache-Control', 'private, no-store'); return response()->json(['data' => $this->metadata($id, $existing)], 200)->header('Cache-Control', 'private, no-store');
@@ -41,37 +37,32 @@ class SimulationOfferDocumentController extends Controller
throw $e; throw $e;
} }
ScanDocumentForMalware::dispatch($documentId)->afterCommit(); ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return response()->json(['data' => $this->metadata($id, DB::table('documents')->where('id', $documentId)->first())], 201)->header('Cache-Control', 'private, no-store'); return response()->json(['data' => $this->metadata($id, DB::table('documents')->where('id', $documentId)->first())], 201)->header('Cache-Control', 'private, no-store');
} }
public function index(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse public function index(Request $request, string $id, SimulationFormDrafts $drafts)
{ {
abort_unless(Str::isUuid($id), 404); abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request); $drafts->find($id, $request);
$documents = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->get(); $documents = DB::table('documents')->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->get();
return response()->json(['data' => $documents->map(fn ($doc) => $this->metadata($id, $doc))])->header('Cache-Control', 'private, no-store'); return response()->json(['data' => $documents->map(fn ($doc) => $this->metadata($id, $doc))])->header('Cache-Control', 'private, no-store');
} }
public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts): BinaryFileResponse public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts)
{ {
abort_unless(Str::isUuid($id) && Str::isUuid($document), 404); abort_unless(Str::isUuid($id) && Str::isUuid($document), 404);
$drafts->find($id, $request); $drafts->find($id, $request);
$doc = DB::table('documents')->where('id', $document)->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->first(); $doc = DB::table('documents')->where('id', $document)->where('source_system', 'simulation-fiscal-offer')->where('external_id', $id)->whereNull('deleted_at')->first();
abort_unless($doc !== null, 404); abort_unless($doc, 404);
abort_unless($doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 409, 'Anexo conservado; descarga disponível após análise antimalware concluída.'); abort_unless($doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 409, 'Anexo conservado; descarga disponível após análise antimalware concluída.');
$root = realpath(Storage::disk('local')->path('simulation-offers/'.$id)); $root = realpath(Storage::disk('local')->path('simulation-offers/'.$id));
$path = realpath(Storage::disk('local')->path($doc->storage_key)); $path = realpath(Storage::disk('local')->path($doc->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404); abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
$hash = hash_file('sha256', $path); abort_unless(hash_equals(trim($doc->sha256), hash_file('sha256', $path)), 409, 'Conteúdo do anexo diverge do hash registado.');
abort_unless(is_string($hash) && hash_equals(trim($doc->sha256), $hash), 409, 'Conteúdo do anexo diverge do hash registado.');
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']); return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
} }
/** @return array{id: string, draftId: string, name: string, sha256: string, scan: string, url: string} */ private function metadata(string $id, object $doc): array
private function metadata(string $id, stdClass $doc): array
{ {
return ['id' => $doc->id, 'draftId' => $id, 'name' => $doc->file_name, 'sha256' => trim($doc->sha256), return ['id' => $doc->id, 'draftId' => $id, 'name' => $doc->file_name, 'sha256' => trim($doc->sha256),
'scan' => $doc->malware_scan_status, 'url' => '/api/v1/simulation-drafts/'.$id.'/offer-documents/'.$doc->id]; 'scan' => $doc->malware_scan_status, 'url' => '/api/v1/simulation-drafts/'.$id.'/offer-documents/'.$doc->id];
@@ -1,27 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\EconomicCluster;
use App\Services\ClusterCompetitiveness;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class ClusterCompetitivenessController extends Controller
{
public function show(EconomicCluster $cluster, ClusterCompetitiveness $service): JsonResponse
{
return response()->json($service->management($cluster->id))->header('Cache-Control', 'private, no-store');
}
public function change(Request $request, EconomicCluster $cluster, string $action, ClusterCompetitiveness $service): JsonResponse
{
$data = $request->validate(['expected_version' => 'required|integer|min:0',
'draft' => $action === 'saved' ? 'required|array:version,clusterId,sector,region,forces' : 'prohibited',
'revision' => $action === 'restored' ? 'required|integer|min:1' : 'prohibited',
'reason' => in_array($action, ['saved', 'restored'], true) ? 'nullable|string|max:3000' : 'required|string|min:10|max:3000']);
return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data))
->header('Cache-Control', 'private, no-store');
}
}
@@ -7,8 +7,6 @@ use App\Models\InvestmentGap;
use App\Models\ValueChain; use App\Models\ValueChain;
use App\Models\ValueChainLink; use App\Models\ValueChainLink;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -23,17 +21,6 @@ class ClusterValueChainController extends Controller
'to_value_chain_id' => ['required', 'uuid', 'different:from_value_chain_id', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')], 'to_value_chain_id' => ['required', 'uuid', 'different:from_value_chain_id', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'link_type' => ['required', Rule::in(['supplies', 'processes', 'distributes', 'supports'])], 'link_type' => ['required', Rule::in(['supplies', 'processes', 'distributes', 'supports'])],
]); ]);
if (app(PortalEditorialSubjects::class)->available()) {
$edition = DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ValueChainLink::where($data)->first();
abort_if($link !== null, 409, 'Ligação existente. Abra a edição desse registo para alterar.');
return app(PortalEditorialSubjects::class)->stageModel($request->user(), 'value-chain-links', new ValueChainLink, $data, 0);
});
return redirect('/management/editorial/subjects/value-chain-links/'.$edition->subject_key)->with('success', 'Ligação guardada numa proposta privada.');
}
DB::transaction(function () use ($cluster, $data, $request) { DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ValueChainLink::where($data)->first() ?? new ValueChainLink; $link = ValueChainLink::where($data)->first() ?? new ValueChainLink;
@@ -45,13 +32,6 @@ class ClusterValueChainController extends Controller
public function removeLink(Request $request, EconomicCluster $cluster, ValueChainLink $link): RedirectResponse public function removeLink(Request $request, EconomicCluster $cluster, ValueChainLink $link): RedirectResponse
{ {
if (app(PortalEditorialSubjects::class)->writes('value-chain-links', $link->id)) {
abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404);
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveSubject($request->user(), 'value-chain-links', $link->id, $workflow->subjectVersion('value-chain-links', $link->id), [], false, true);
return redirect('/management/editorial/subjects/value-chain-links/'.$link->id)->with('success', 'Retirada proposta. A ligação actual mantém-se até à publicação da decisão.');
}
DB::transaction(function () use ($cluster, $link, $request) { DB::transaction(function () use ($cluster, $link, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404); abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404);
@@ -64,7 +44,6 @@ class ClusterValueChainController extends Controller
public function gap(Request $request, EconomicCluster $cluster, ?InvestmentGap $gap = null): RedirectResponse public function gap(Request $request, EconomicCluster $cluster, ?InvestmentGap $gap = null): RedirectResponse
{ {
abort_if($gap && $gap->cluster_id !== $cluster->id, 404); abort_if($gap && $gap->cluster_id !== $cluster->id, 404);
$editorial = $gap ? app(PortalEditorialSubjects::class)->writes('investment-gaps', $gap->id) : app(PortalEditorialSubjects::class)->available();
$data = $request->validate([ $data = $request->validate([
'value_chain_id' => ['required', 'uuid', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')], 'value_chain_id' => ['required', 'uuid', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'title' => ['required', 'string', 'max:200'], 'title' => ['required', 'string', 'max:200'],
@@ -73,13 +52,7 @@ class ClusterValueChainController extends Controller
'rationale' => ['required', 'string', 'min:10', 'max:5000'], 'rationale' => ['required', 'string', 'min:10', 'max:5000'],
'status' => ['required', Rule::in(['identified', 'in_review', 'resolved', 'withdrawn'])], 'status' => ['required', Rule::in(['identified', 'in_review', 'resolved', 'withdrawn'])],
'call_to_action' => ['required', 'string', 'max:2000'], 'call_to_action' => ['required', 'string', 'max:2000'],
...($editorial && $gap ? ['editorial_version' => ['required', 'integer', 'min:1']] : []),
]); ]);
if ($editorial) {
$edition = app(PortalEditorialSubjects::class)->stageModel($request->user(), 'investment-gaps', $gap ?? new InvestmentGap, [...array_diff_key($data, ['editorial_version' => true]), 'cluster_id' => $cluster->id], $gap ? (int) $data['editorial_version'] : 0);
return redirect('/management/editorial/subjects/investment-gaps/'.$edition->subject_key)->with('success', 'Lacuna guardada numa proposta privada.');
}
PortalAudit::save($gap ?? new InvestmentGap, [...$data, 'cluster_id' => $cluster->id], $request->user()->id); PortalAudit::save($gap ?? new InvestmentGap, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Lacuna de investimento guardada.'); return back()->with('success', 'Lacuna de investimento guardada.');
@@ -1,33 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\CompanyComparison;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class CompanyComparisonController extends Controller
{
public function index(Request $request, CompanyComparison $service): Response|JsonResponse
{
$answers = $service->answers($request->all());
$data = ['definition' => $service->definition(), 'answers' => $answers, 'result' => $service->evaluate($answers),
'case' => null, 'saved' => null, 'canWrite' => false, 'history' => [], 'historyNext' => null, 'historical' => false, 'workingCopy' => false];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/company-comparison', $data);
}
public function show(Request $request, string $id, CompanyComparison $service): Response|JsonResponse
{
$data = ['definition' => $service->definition(), ...$service->view($request->user(), $id, $request->all())];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/company-comparison', $data);
}
public function save(Request $request, string $id, CompanyComparison $service): JsonResponse
{
return response()->json($service->save($request->user(), $id, $request->all()));
}
}
@@ -1,24 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\CompanyPreparation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class CompanyPreparationController extends Controller
{
public function show(Request $request, string $id, CompanyPreparation $service): Response|JsonResponse
{
$data = $service->view($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/company-preparation', $data);
}
public function save(Request $request, string $id, CompanyPreparation $service): JsonResponse
{
return response()->json($service->save($request->user(), $id, $request->all()));
}
}
@@ -7,11 +7,9 @@ use App\Services\CorridorAgribusinessOpportunities;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\OpportunityPromotion; use App\Services\OpportunityPromotion;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response;
final class CorridorAgribusinessController extends Controller final class CorridorAgribusinessController extends Controller
{ {
@@ -20,36 +18,31 @@ final class CorridorAgribusinessController extends Controller
return hash('sha256', json_encode($service->entries(), JSON_THROW_ON_ERROR)); return hash('sha256', json_encode($service->entries(), JSON_THROW_ON_ERROR));
} }
public function show(Request $request, CorridorAgribusinessOpportunities $service): Response public function show(Request $request, CorridorAgribusinessOpportunities $service)
{ {
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
abort_unless($access->internalAssignment($request->user(), 'investment_project') || $access->has($request->user(), 'publisher'), 403); abort_unless($access->internalAssignment($request->user(), 'investment_project') || $access->has($request->user(), 'publisher'), 403);
$records = Opportunity::withTrashed()->whereIn('management_creation_key', array_column($service->entries(), 'key'))->get()->keyBy('management_creation_key'); $records = Opportunity::withTrashed()->whereIn('management_creation_key', array_column($service->entries(), 'key'))->get()->keyBy('management_creation_key');
foreach ($records as $record) { foreach ($records as $record) abort_unless($access->canView($request->user(), $record), 403);
abort_unless($access->canView($request->user(), $record), 403);
}
return Inertia::render('management/corridor-agribusiness', ['manifestHash' => $this->manifest($service), return Inertia::render('management/corridor-agribusiness', ['manifestHash' => $this->manifest($service),
'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'), 'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'),
'entries' => array_map(function ($entry) use ($records) { 'entries' => array_map(function ($entry) use ($records) {
$r = $records->get($entry['key']); $r = $records->get($entry['key']);
return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null]; return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null];
}, $service->entries())]); }, $service->entries())]);
} }
public function register(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse public function register(Request $request, CorridorAgribusinessOpportunities $service)
{ {
$data = $request->validate(['manifest_hash' => 'required|string|size:64', 'confirm' => 'required|accepted']); $data = $request->validate(['manifest_hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
abort_unless(hash_equals($this->manifest($service), $data['manifest_hash']), 409, 'A fonte mudou; recupere a prévia.'); abort_unless(hash_equals($this->manifest($service), $data['manifest_hash']), 409, 'A fonte mudou; recupere a prévia.');
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
$grant = $access->internalAssignment($request->user(), 'investment_project'); $grant = $access->internalAssignment($request->user(), 'investment_project');
abort_unless($grant && $access->mfa($request->user()), 403, 'Use a designação existente e MFA recente.'); abort_unless($grant && $access->mfa($request->user()), 403, 'Use a designação existente e MFA recente.');
return response()->json(['records' => $service->register($request->user(), $grant->organization_id)]); return response()->json(['records' => $service->register($request->user(), $grant->organization_id)]);
} }
public function preview(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse public function preview(Request $request, CorridorAgribusinessOpportunities $service)
{ {
$previews = []; $previews = [];
foreach ($service->entries() as $entry) { foreach ($service->entries() as $entry) {
@@ -59,27 +52,21 @@ final class CorridorAgribusinessController extends Controller
$previews[] = ['id' => $record->id, ...app(OpportunityPromotion::class)->preview($request->user(), $record, $previews[] = ['id' => $record->id, ...app(OpportunityPromotion::class)->preview($request->user(), $record,
[...$input, 'source_date' => $input['source_date'] ?: null, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])]; [...$input, 'source_date' => $input['source_date'] ?: null, 'action' => 'disclose', 'etag' => app(OpportunityManagement::class)->etag($record)])];
} }
return response()->json(['previews' => $previews]); return response()->json(['previews' => $previews]);
} }
public function disclose(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse public function disclose(Request $request, CorridorAgribusinessOpportunities $service)
{ {
$data = $request->validate(['confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000', $data = $request->validate(['confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000',
'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|distinct', 'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|distinct',
'previews.*.token' => 'required|string|max:300000', 'previews.*.operation_key' => 'required|uuid|distinct']); 'previews.*.token' => 'required|string|max:300000', 'previews.*.operation_key' => 'required|uuid|distinct']);
$expected = Opportunity::whereIn('management_creation_key', array_column($service->entries(), 'key'))->pluck('id')->sort()->values()->all(); $expected = Opportunity::whereIn('management_creation_key', array_column($service->entries(), 'key'))->pluck('id')->sort()->values()->all();
$supplied = array_column($data['previews'], 'id'); $supplied = array_column($data['previews'], 'id'); sort($supplied);
sort($supplied);
abort_unless($expected === $supplied && count($expected) === 34, 409, 'A prévia deve identificar exactamente as 34 fichas deste lote.'); abort_unless($expected === $supplied && count($expected) === 34, 409, 'A prévia deve identificar exactamente as 34 fichas deste lote.');
return response()->json(DB::transaction(function () use ($request, $data) { return response()->json(DB::transaction(function () use ($request, $data) {
$result = []; $result = [];
foreach ($data['previews'] as $preview) { foreach ($data['previews'] as $preview) $result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'],
$result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'], [...$preview, 'confirm' => true, 'reason' => $data['reason']]);
[...$preview, 'confirm' => true, 'reason' => $data['reason']]);
}
return ['records' => $result]; return ['records' => $result];
})); }));
} }
+26 -64
View File
@@ -10,14 +10,9 @@ use App\Models\Lead;
use App\Models\User; use App\Models\User;
use App\Services\ConvertInterestToLead; use App\Services\ConvertInterestToLead;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\CrmNextAction;
use App\Services\CrmTaskCreation;
use App\Services\CrmTaskPreparations;
use App\Services\DeclaredOriginStatistics; use App\Services\DeclaredOriginStatistics;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use App\Services\VirtualDesk;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -28,12 +23,6 @@ use Inertia\Response;
class CrmController extends Controller class CrmController extends Controller
{ {
private const VERSION_MESSAGES = [
'version.required' => 'Actualize os dados para recuperar a versão deste registo antes de gravar. As suas edições foram conservadas.',
'version.integer' => 'A versão do registo não foi reconhecida. Recupere os dados actuais.',
'version.min' => 'A versão do registo não foi reconhecida. Recupere os dados actuais.',
];
public function investors(Request $request): Response public function investors(Request $request): Response
{ {
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]); $filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
@@ -54,12 +43,10 @@ class CrmController extends Controller
public function saveInvestor(Request $request, Investor $investor): RedirectResponse public function saveInvestor(Request $request, Investor $investor): RedirectResponse
{ {
$data = $request->validate(['version' => 'required|integer|min:1', 'legal_name' => ['required', 'string', 'max:200'], $data = $request->validate(['legal_name' => ['required', 'string', 'max:200'],
'investor_type' => ['required', Rule::in(['domestic', 'foreign', 'institutional', 'individual'])], 'investor_type' => ['required', Rule::in(['domestic', 'foreign', 'institutional', 'individual'])],
'status' => ['required', Rule::in(['prospect', 'active', 'inactive'])]], self::VERSION_MESSAGES); 'status' => ['required', Rule::in(['prospect', 'active', 'inactive'])]]);
$version = (int) $data['version']; PortalAudit::save($investor, $data, $request->user()->id);
unset($data['version']);
PortalAudit::save($investor, $data, $request->user()->id, $version);
return back()->with('success', 'Investidor actualizado.'); return back()->with('success', 'Investidor actualizado.');
} }
@@ -67,17 +54,15 @@ class CrmController extends Controller
public function saveContact(Request $request, Investor $investor, Contact $contact): RedirectResponse public function saveContact(Request $request, Investor $investor, Contact $contact): RedirectResponse
{ {
abort_unless(DB::table('investor_contacts')->where('investor_id', $investor->id)->where('contact_id', $contact->id)->exists(), 404); abort_unless(DB::table('investor_contacts')->where('investor_id', $investor->id)->where('contact_id', $contact->id)->exists(), 404);
$data = $request->validate(['version' => 'required|integer|min:1', 'full_name' => ['required', 'string', 'max:150'], $data = $request->validate(['full_name' => ['required', 'string', 'max:150'],
'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30']], self::VERSION_MESSAGES); 'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30']]);
$data['email'] = strtolower(trim($data['email'])); $data['email'] = strtolower(trim($data['email']));
$version = (int) $data['version']; DB::transaction(function () use ($contact, $data, $request) {
unset($data['version']);
DB::transaction(function () use ($contact, $data, $request, $version) {
DB::select('select pg_advisory_xact_lock(170925, hashtext(?))', [$data['email']]); DB::select('select pg_advisory_xact_lock(170925, hashtext(?))', [$data['email']]);
if (Contact::where('id', '!=', $contact->id)->whereRaw('lower(trim(email)) = ?', [$data['email']])->exists()) { if (Contact::where('id', '!=', $contact->id)->whereRaw('lower(trim(email)) = ?', [$data['email']])->exists()) {
throw ValidationException::withMessages(['email' => 'Já existe outro contacto com este email. Reveja os registos antes de os fundir.']); throw ValidationException::withMessages(['email' => 'Já existe outro contacto com este email. Reveja os registos antes de os fundir.']);
} }
PortalAudit::save($contact, $data, $request->user()->id, $version); PortalAudit::save($contact, $data, $request->user()->id);
}); });
return back()->with('success', 'Contacto actualizado.'); return back()->with('success', 'Contacto actualizado.');
@@ -97,7 +82,7 @@ class CrmController extends Controller
'owner_user_id' => ['nullable', 'integer', 'exists:users,id'], 'owner_user_id' => ['nullable', 'integer', 'exists:users,id'],
'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]); 'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]);
$query = Lead::with(['investor:id,legal_name', 'owner:id,name']) $query = Lead::with(['investor:id,legal_name', 'owner:id,name'])
->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->availableForWork()->where('due_at', '<', now())]); ->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())]);
if ($term = $filters['q'] ?? null) { if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('title', "%{$term}%") $query->where(fn ($q) => $q->whereLike('title', "%{$term}%")
->orWhereHas('investor', fn ($investor) => $investor->whereLike('legal_name', "%{$term}%"))); ->orWhereHas('investor', fn ($investor) => $investor->whereLike('legal_name', "%{$term}%")));
@@ -142,37 +127,29 @@ class CrmController extends Controller
return redirect('/management/crm/leads/'.$lead->id)->with('success', 'Pedido convertido em lead com responsável e próxima acção.'); return redirect('/management/crm/leads/'.$lead->id)->with('success', 'Pedido convertido em lead com responsável e próxima acção.');
} }
public function show(Request $request, Lead $lead): Response public function show(Lead $lead): Response
{ {
$lead->load(['investor', 'owner:id,name', 'tasks' => fn ($q) => $q->orderBy('due_at')]); $lead->load(['investor', 'owner:id,name', 'tasks' => fn ($q) => $q->orderBy('due_at')]);
$caseIds = $lead->tasks->pluck('desk_case_id')->filter();
$closedCases = $caseIds->isEmpty() ? [] : DB::table('desk_cases')->whereIn('id', $caseIds)->whereNotNull('closed_at')->pluck('id')->all();
foreach ($lead->tasks as $task) {
$task->setAttribute('can_update', ! in_array($task->getAttribute('desk_case_id'), $closedCases, true));
}
$contacts = DB::table('investor_contacts')->join('contacts', 'contacts.id', '=', 'investor_contacts.contact_id') $contacts = DB::table('investor_contacts')->join('contacts', 'contacts.id', '=', 'investor_contacts.contact_id')
->where('investor_id', $lead->investor_id)->whereNull('contacts.deleted_at')->get(['contacts.id', 'full_name', 'email', 'phone']); ->where('investor_id', $lead->investor_id)->whereNull('contacts.deleted_at')->get(['contacts.id', 'full_name', 'email', 'phone']);
return Inertia::render('crm/lead', ['lead' => $lead, 'contacts' => $contacts, 'owners' => $this->owners(), 'taskCreation' => app(CrmTaskCreation::class)->context($request->user(), $lead), return Inertia::render('crm/lead', ['lead' => $lead, 'contacts' => $contacts, 'owners' => $this->owners()]);
'nextAction' => ['future_tasks' => app(CrmNextAction::class)->future($lead)->count()]]);
} }
public function update(Request $request, Lead $lead): RedirectResponse public function update(Request $request, Lead $lead): RedirectResponse
{ {
$data = $request->validate(['version' => 'required|integer|min:1', 'owner_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))], $data = $request->validate(['owner_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))],
'status' => ['required', Rule::in(['new', 'qualified', 'disqualified'])], 'status' => ['required', Rule::in(['new', 'qualified', 'disqualified'])],
'qualification_reason' => ['required', 'string', 'min:10', 'max:2000']], self::VERSION_MESSAGES); 'qualification_reason' => ['required', 'string', 'min:10', 'max:2000']]);
$version = (int) $data['version']; DB::transaction(function () use ($lead, $data, $request) {
unset($data['version']);
DB::transaction(function () use ($lead, $data, $request, $version) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id); $lead = Lead::lockForUpdate()->findOrFail($lead->id);
if ($lead->status === 'converted') { if ($lead->status === 'converted') {
throw ValidationException::withMessages(['status' => 'O lead já foi convertido numa negociação.']); throw ValidationException::withMessages(['status' => 'O lead já foi convertido numa negociação.']);
} }
if ($data['status'] !== 'disqualified' && ! app(CrmNextAction::class)->future($lead)->exists()) { if ($data['status'] !== 'disqualified' && ! $lead->tasks()->whereIn('status', ['open', 'in_progress'])->where('due_at', '>', now())->exists()) {
throw ValidationException::withMessages(['status' => 'Registe uma próxima acção com prazo futuro antes de activar ou qualificar o lead.']); throw ValidationException::withMessages(['status' => 'Registe uma próxima acção com prazo futuro antes de activar ou qualificar o lead.']);
} }
PortalAudit::save($lead, $data, $request->user()->id, $version); PortalAudit::save($lead, $data, $request->user()->id);
}); });
return back()->with('success', 'Lead actualizado.'); return back()->with('success', 'Lead actualizado.');
@@ -180,39 +157,24 @@ class CrmController extends Controller
public function task(Request $request, Lead $lead): RedirectResponse public function task(Request $request, Lead $lead): RedirectResponse
{ {
$result = app(CrmTaskCreation::class)->create($request->user(), $lead, $request->all(), $this->owners()->pluck('id')->all()); $data = $request->validate(['title' => ['required', 'string', 'max:200'], 'due_at' => ['required', 'date', 'after:now'],
'assignee_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))]]);
PortalAudit::save(new CrmTask, [...$data, 'lead_id' => $lead->id, 'status' => 'open'], $request->user()->id);
return redirect('/management/crm/leads/'.$lead->id)->with('success', $result['replayed'] ? 'A tarefa já tinha sido registada. A criação não foi repetida.' : 'Tarefa criada.'); return back()->with('success', 'Tarefa criada.');
}
public function taskPreparation(Request $request, Lead $lead): JsonResponse
{
// Preserve the user's partial text; business validation still occurs only on task creation.
$input = json_decode($request->getContent(), true);
if (! is_array($input)) {
throw ValidationException::withMessages(['snapshot' => 'A preparação deve ser enviada em JSON.']);
}
return response()->json(app(CrmTaskPreparations::class)->save($request->user(), $lead, $input));
} }
public function updateTask(Request $request, Lead $lead, CrmTask $task): RedirectResponse public function updateTask(Request $request, Lead $lead, CrmTask $task): RedirectResponse
{ {
abort_unless($task->lead_id === $lead->id, 404); abort_unless($task->lead_id === $lead->id, 404);
$data = $request->validate(['version' => 'required|integer|min:1', 'status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])]], self::VERSION_MESSAGES); $data = $request->validate(['status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])]]);
$version = (int) $data['version']; DB::transaction(function () use ($lead, $task, $data, $request) {
unset($data['version']);
DB::transaction(function () use ($lead, $task, $data, $request, $version) {
if ($task->getAttribute('desk_case_id')) {
$case = DB::table('desk_cases')->where('id', $task->getAttribute('desk_case_id'))->lockForUpdate()->first();
abort_unless($case !== null, 404);
app(VirtualDesk::class)->assertOpen($case);
}
$lead = Lead::lockForUpdate()->findOrFail($lead->id); $lead = Lead::lockForUpdate()->findOrFail($lead->id);
$task = CrmTask::lockForUpdate()->findOrFail($task->id); if (in_array($lead->status, ['new', 'qualified'], true) && in_array($data['status'], ['done', 'cancelled'], true)
abort_unless($task->lead_id === $lead->id, 404); && ! $lead->tasks()->where('id', '!=', $task->id)->whereIn('status', ['open', 'in_progress'])->where('due_at', '>', now())->exists()) {
app(CrmNextAction::class)->assertMayFinish($lead, $task, $data['status']); throw ValidationException::withMessages(['status' => 'Crie a próxima acção antes de encerrar a última tarefa de um lead activo.']);
PortalAudit::save($task, $data, $request->user()->id, $version); }
PortalAudit::save($task, $data, $request->user()->id);
}); });
return back()->with('success', 'Tarefa actualizada.'); return back()->with('success', 'Tarefa actualizada.');
+3 -11
View File
@@ -5,9 +5,7 @@ namespace App\Http\Controllers;
use App\Models\InterestRequest; use App\Models\InterestRequest;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Services\DashboardWorkQueue;
use App\Services\InvestorPath; use App\Services\InvestorPath;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -18,18 +16,12 @@ class DashboardController extends Controller
{ {
public function __invoke(Request $request): Response public function __invoke(Request $request): Response
{ {
$validated = $request->validate(['interests_page' => 'sometimes|integer|min:1', 'desk_page' => 'sometimes|integer|min:1', 'crm_tasks_page' => 'sometimes|integer|min:1']); if (! $request->user()->can('manage-portal')) {
$pages = [];
foreach (['interests_page', 'desk_page', 'crm_tasks_page'] as $key) {
$pages[$key] = (int) ($validated[$key] ?? 1);
}
$workQueue = app(DashboardWorkQueue::class)->forActor($request->user(), $pages);
if (! $request->user()->can('manage-portal') || app(PortalProfileAccess::class)->has($request->user(), 'auditor')) {
$path = app(InvestorPath::class); $path = app(InvestorPath::class);
$contexts = $path->contexts($request->user()); $contexts = $path->contexts($request->user());
$current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null; $current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null;
return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [ return Inertia::render('dashboard', ['summary' => null, 'investorPath' => $path->available() ? [
'contexts' => $contexts, 'contexts' => $contexts,
'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'], 'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'],
'pending' => count(array_filter($current['steps'], fn ($step) => ! in_array($step['state'], ['completed', 'approved', 'dispensed'], true))), 'pending' => count(array_filter($current['steps'], fn ($step) => ! in_array($step['state'], ['completed', 'approved', 'dispensed'], true))),
@@ -44,7 +36,7 @@ class DashboardController extends Controller
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user()); $drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
} }
return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'workQueue' => $workQueue, 'summary' => [ return Inertia::render('dashboard', ['summary' => [
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed']) 'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]), ->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
@@ -1,83 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\DemographicEvidence;
use App\Services\DemographicImport;
use App\Services\DemographicInstallation;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
final class DemographyController extends Controller
{
public function index(Request $request, DemographicEvidence $service): Response|JsonResponse
{
$data = $request->validate(['territories' => 'nullable|array|max:4', 'territories.*' => 'required|uuid|distinct',
'start_year' => 'nullable|integer|between:1900,2100', 'end_year' => 'nullable|integer|between:1900,2100|gte:start_year',
'sector' => 'nullable|in:'.implode(',', array_keys(DemographicEvidence::sectors())), 'edition' => 'nullable|uuid']);
$start = (int) ($data['start_year'] ?? date('Y'));
$end = (int) ($data['end_year'] ?? $start);
abort_unless($end >= $start && $end - $start <= 50, 422, 'Escolha um horizonte até 50 anos.');
$evidence = $service->analyse($data['territories'] ?? [], $start, $end, $data['sector'] ?? 'general', $data['edition'] ?? null);
if ($request->expectsJson()) {
return response()->json($evidence);
}
$territories = $service->territories()->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']);
return Inertia::render('investment/demography', ['evidence' => $evidence, 'filters' => $data, 'territories' => $territories, 'sectors' => DemographicEvidence::sectors()]);
}
public function management(Request $request, DemographicImport $service): Response|RedirectResponse
{
if (! Schema::hasTable('demographic_imports')) {
return redirect('/management/demography/install');
}
$service->authorize($request->user());
$data = $request->validate(['batch' => 'nullable|uuid']);
$workflow = app(PortalEditorialWorkflow::class);
$editorial = $workflow->demographyAvailable();
return Inertia::render('management/demography', ['editorialEnabled' => $editorial, 'editorialRevision' => $editorial && isset($data['batch']) ? (int) ($workflow->latestDemography($data['batch'])->revision_no ?? 0) : 0, 'qa' => ! app(DemographicInstallation::class)->activeTarget(), 'batches' => DB::table('demographic_imports')->orderByDesc('created_at')->get(['id', 'file_name', 'publication_version', 'status', 'created_at']),
'preview' => isset($data['batch']) ? $service->preview($request->user(), $data['batch']) : null]);
}
public function installation(Request $request, DemographicInstallation $service): Response
{
return Inertia::render('management/demography-install', ['plan' => $service->preview($request->user())]);
}
public function install(Request $request, DemographicInstallation $service): JsonResponse
{
return response()->json($service->apply($request->user(), $request->all()));
}
public function prepare(Request $request, DemographicImport $service): JsonResponse
{
$data = $request->validate(['file' => 'required|file|mimes:xlsx|max:19531', 'publication_version' => 'required|string|max:255', 'baseline_year' => 'nullable|integer|between:1900,2100']);
$file = $request->file('file');
return response()->json($service->prepare($request->user(), $file->getRealPath(), $file->getClientOriginalName(), $data['publication_version'], isset($data['baseline_year']) ? (int) $data['baseline_year'] : null));
}
public function confirm(Request $request, string $id, DemographicImport $service): JsonResponse
{
$data = $request->validate(['sha256' => 'required|string|size:64', 'mapping' => 'present|array', 'mapping.*' => 'required|uuid', 'reason' => 'required|string|min:10|max:2000']);
return response()->json($service->confirm($request->user(), $id, $data['sha256'], $data['mapping'], $data['reason']));
}
public function transition(Request $request, string $id, DemographicImport $service): JsonResponse
{
$data = $request->validate(['action' => 'required|in:approved,disabled', 'reason' => 'required|string|min:10|max:2000']);
$service->transition($request->user(), $id, $data['action'], $data['reason']);
return response()->json(['id' => $id, 'status' => $data['action']]);
}
}
@@ -1,20 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\DeskRequestCheckpoints;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class DeskRequestCheckpointController extends Controller
{
public function show(Request $request, string $id, DeskRequestCheckpoints $checkpoints): JsonResponse
{
return response()->json($checkpoints->show($request->user(), $id));
}
public function save(Request $request, string $id, DeskRequestCheckpoints $checkpoints): JsonResponse
{
return response()->json($checkpoints->save($request->user(), $id, $request->all()));
}
}
@@ -1,36 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\DeskServiceAvailability;
use App\Services\DeskServiceEditor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class DeskServiceEditorController extends Controller
{
public function index(Request $request, DeskServiceEditor $editor): Response|JsonResponse
{
$data = ['services' => $editor->listing(), 'schedulingAvailable' => app(DeskServiceAvailability::class)->scheduled(), 'today' => now()->toDateString(), 'calendarTimezone' => config('app.timezone')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/service-editor', $data);
}
public function publish(Request $request, DeskServiceEditor $editor, string $code): JsonResponse|RedirectResponse
{
$row = $editor->publish($request->user(), $code, $request->all());
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version, 'effective_from' => $row->effective_from ?? null], 201)
: back()->with('success', 'Versão publicada'.(($row->effective_from ?? null) ? ' com entrada em vigor em '.$row->effective_from : '').'. Os pedidos existentes conservam a sua versão.');
}
public function history(Request $request, DeskServiceEditor $editor, string $code): JsonResponse
{
$data = $request->validate(['page' => ['nullable', 'integer', 'min:1']]);
return response()->json($editor->history($code, (int) ($data['page'] ?? 1)));
}
}
@@ -2,28 +2,27 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\Malware\DocumentScanRetry; use App\Jobs\ScanDocumentForMalware;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DocumentMalwareController extends Controller class DocumentMalwareController extends Controller
{ {
public function retry(Request $request, string $id): RedirectResponse|JsonResponse public function retry(Request $request, string $id): RedirectResponse
{ {
$result = app(DocumentScanRetry::class)->request($request->user(), $id); DB::transaction(function () use ($id, $request): void {
$message = match ($result['outcome']) { $document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first();
'accepted' => 'Pedido de análise aceite pelo serviço de envio. Acompanhe o estado do ficheiro até haver resultado.', abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
'duplicate' => 'Já existe uma reserva de análise para este ficheiro. Não foi enviado outro pedido. Acompanhe o estado antes de repetir.', abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
'stale' => 'O ficheiro ou o seu acesso mudou. Actualize a página para consultar o estado antes de repetir.', 'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
'unconfirmed' => 'Não foi possível confirmar o envio da análise. Consulte o estado antes de repetir. Referência: '.$result['reference'],
default => 'Pedido preparado para envio após a confirmação da gravação. Acompanhe o estado do ficheiro.',
};
if ($request->expectsJson()) { DB::table('documents')->where('id', $id)->update([
return response()->json([...$result, 'message' => $message], $result['outcome'] === 'unconfirmed' ? 503 : 200)->header('Cache-Control', 'no-store, private'); 'status' => 'quarantined', 'malware_scan_status' => 'pending', 'malware_failure_reason' => null, 'updated_at' => now(),
} ]);
ScanDocumentForMalware::dispatch($id)->afterCommit();
});
return back()->with($result['outcome'] === 'accepted' ? 'success' : 'warning', $message); return back()->with('success', 'Nova análise colocada na fila. O ficheiro permanece privado até haver resultado.');
} }
} }
@@ -2,80 +2,63 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\DocumentManagementAccess; use App\Services\VerifiedDocumentBinary;
use App\Services\EditorialInstallation;
use App\Services\EditorialReading;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
use Laravel\Fortify\Fortify;
use Symfony\Component\HttpFoundation\Response;
class EditorialArticleController extends Controller class EditorialArticleController extends Controller
{ {
public function installation(Request $request, EditorialInstallation $installation): Response public function installation(Request $request, \App\Services\EditorialInstallation $installation)
{ {
$plan = $installation->preview($request->user()); $plan = $installation->preview($request->user());
return $request->expectsJson() ? response()->json(['plan' => $plan])->header('Cache-Control', 'private, no-store') return $request->expectsJson() ? response()->json(['plan' => $plan])->header('Cache-Control', 'private, no-store')
: response()->view('management.editorial-installation', ['plan' => $plan, 'operationKey' => (string) Str::uuid(), 'receipt' => null])->header('Cache-Control', 'private, no-store'); : response()->view('management.editorial-installation', ['plan' => $plan, 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => null])->header('Cache-Control', 'private, no-store');
} }
public function install(Request $request, EditorialInstallation $installation): Response public function install(Request $request, \App\Services\EditorialInstallation $installation)
{ {
$receipt = $installation->apply($request->user(), $request->all()); $receipt = $installation->apply($request->user(), $request->all());
return $request->expectsJson() ? response()->json(['receipt' => $receipt])->header('Cache-Control', 'private, no-store') return $request->expectsJson() ? response()->json(['receipt' => $receipt])->header('Cache-Control', 'private, no-store')
: response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store'); : response()->view('management.editorial-installation', ['plan' => $installation->preview($request->user()), 'operationKey' => (string) \Illuminate\Support\Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store');
} }
public function showMfa(Request $request): Response public function showMfa(Request $request)
{ {
return $this->confirmMfa($request); return $this->confirmMfa($request);
} }
public function confirmMfa(Request $request): Response public function confirmMfa(Request $request)
{ {
$actor = $request->user(); $actor = $request->user();
abort_unless($actor->two_factor_secret !== null && $actor->two_factor_confirmed_at !== null, 403, 'MFA configurado e confirmado necessário.'); abort_unless($actor->two_factor_secret !== null && $actor->two_factor_confirmed_at !== null, 403, 'MFA configurado e confirmado necessário.');
if (app(PortalProfileAccess::class)->mfa($actor)) { if (app(\App\Services\PortalProfileAccess::class)->mfa($actor)) {
return redirect()->route('editorial.installation'); return redirect()->route('editorial.installation');
} }
if ($request->isMethod('get')) { if ($request->isMethod('get')) {
return response()->view('management.editorial-mfa')->header('Cache-Control', 'private, no-store'); return response()->view('management.editorial-mfa')->header('Cache-Control', 'private, no-store');
} }
abort_if(array_diff(array_keys($request->all()), ['code', 'recovery_code', '_token']) !== [], 422, 'A conta e o destino são obtidos da sessão e do servidor.'); abort_if(array_diff(array_keys($request->all()), ['code', 'recovery_code', '_token']), 422, 'A conta e o destino são obtidos da sessão e do servidor.');
$data = $request->validate(['code' => 'nullable|string|max:32', 'recovery_code' => 'nullable|string|max:255']); $data = $request->validate(['code' => 'nullable|string|max:32', 'recovery_code' => 'nullable|string|max:255']);
try { try {
$recovery = collect($actor->recoveryCodes())->first(fn ($code) => isset($data['recovery_code']) && hash_equals($code, $data['recovery_code'])); $recovery = collect($actor->recoveryCodes())->first(fn ($code) => isset($data['recovery_code']) && hash_equals($code, $data['recovery_code']));
$valid = $recovery !== null || (! empty($data['code']) && app(TwoFactorAuthenticationProvider::class)->verify( $valid = $recovery !== null || (!empty($data['code']) && app(\Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider::class)->verify(
Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code'] \Laravel\Fortify\Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code']
)); ));
} catch (DecryptException $e) { } catch (\Illuminate\Contracts\Encryption\DecryptException $e) {
$valid = false; $valid = false;
} }
if (! $valid) { if (!$valid) {
event(new TwoFactorAuthenticationFailed($actor)); event(new \Laravel\Fortify\Events\TwoFactorAuthenticationFailed($actor));
throw ValidationException::withMessages(['code' => 'Não foi possível confirmar o código MFA.']); throw ValidationException::withMessages(['code' => 'Não foi possível confirmar o código MFA.']);
} }
if ($recovery !== null) { if ($recovery !== null) {
$actor->replaceRecoveryCode($recovery); $actor->replaceRecoveryCode($recovery);
} }
event(new ValidTwoFactorAuthenticationCodeProvided($actor)); event(new \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($actor));
$request->session()->regenerate(); $request->session()->regenerate();
return redirect()->route('editorial.installation')->header('Cache-Control', 'private, no-store'); return redirect()->route('editorial.installation')->header('Cache-Control', 'private, no-store');
} }
@@ -84,7 +67,7 @@ class EditorialArticleController extends Controller
abort_unless(Schema::hasColumn('portal_resources', 'editorial_content'), 503, 'Editor editorial ainda não instalado.'); abort_unless(Schema::hasColumn('portal_resources', 'editorial_content'), 503, 'Editor editorial ainda não instalado.');
} }
private function publicResources(): Builder private function publicResources()
{ {
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized') ->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized')
@@ -92,38 +75,29 @@ class EditorialArticleController extends Controller
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256'); ->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256');
} }
public function edit(int $id): Response public function edit(int $id)
{ {
$this->installed(); $this->installed();
app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id); app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->where('id', $id)->first(); $r = DB::table('portal_resources')->find($id);
abort_unless($r instanceof \stdClass, 404); abort_unless($r, 404);
$workflow = app(PortalEditorialWorkflow::class); $reading = app(\App\Services\EditorialReading::class);
if ($workflow->enabled()) {
$workflow->authorize(request()->user(), $id);
$r = $workflow->workingResource($r);
}
$reading = app(EditorialReading::class);
$original = $reading->original($r); $original = $reading->original($r);
$content = $reading->content($r, $original); $content = $reading->content($r, $original);
return Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
return tap(Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
'workflowEnabled' => $workflow->enabled(), 'workflow' => $workflow->latest($id),
'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']), 'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']),
'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request()), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); 'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request())->header('Cache-Control', 'private, no-store');
} }
public function save(Request $request, int $id): RedirectResponse public function save(Request $request, int $id)
{ {
$this->installed(); $this->installed();
$data = $this->validateContent($request); $data = $this->validateContent($request);
DB::transaction(function () use ($request, $id, $data) { DB::transaction(function () use ($request, $id, $data) {
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); $r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r !== null, 404); abort_unless($r, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$workflow = app(PortalEditorialWorkflow::class); abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
$current = $workflow->enabled() ? $workflow->workingResource($r) : $r;
abort_unless((int) $current->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
if (! empty($data['image_resource_id'])) { if (! empty($data['image_resource_id'])) {
abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.'); abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
} }
@@ -131,16 +105,10 @@ class EditorialArticleController extends Controller
if ($body === '') { if ($body === '') {
throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']); throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']);
} }
$workflow = app(PortalEditorialWorkflow::class); $content = $this->edition($r, $data);
$content = $this->edition($workflow->enabled() ? $workflow->workingResource($r) : $r, $data);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, (int) $data['metadata_version'], ['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR)]);
return;
}
$revision = (int) $r->metadata_version + 1; $revision = (int) $r->metadata_version + 1;
DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]); DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]);
$resource = DB::table('portal_resources')->where('id', $id)->first(); $resource = DB::table('portal_resources')->find($id);
$links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get(); $links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get();
DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => 'metadata_updated', 'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR), 'actor_user_id' => $request->user()->id, 'created_at' => now()]); DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => 'metadata_updated', 'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR), 'actor_user_id' => $request->user()->id, 'created_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'portal-management', 'actor_user_id' => $request->user()->id, 'entity_type' => 'portal_resources', 'entity_id' => null, 'action' => 'editorial_updated', 'before_data' => json_encode(['record_id' => $id, 'metadata_version' => $r->metadata_version]), 'after_data' => json_encode(['record_id' => $id, 'metadata_version' => $revision, 'published' => false])]); DB::table('audit_events')->insert(['source_system' => 'portal-management', 'actor_user_id' => $request->user()->id, 'entity_type' => 'portal_resources', 'entity_id' => null, 'action' => 'editorial_updated', 'before_data' => json_encode(['record_id' => $id, 'metadata_version' => $r->metadata_version]), 'after_data' => json_encode(['record_id' => $id, 'metadata_version' => $revision, 'published' => false])]);
@@ -149,74 +117,46 @@ class EditorialArticleController extends Controller
return redirect('/management/portal-services/resources/'.$id.'/article')->with('success', 'Artigo guardado como rascunho. Publique expressamente no acervo quando estiver pronto.'); return redirect('/management/portal-services/resources/'.$id.'/article')->with('success', 'Artigo guardado como rascunho. Publique expressamente no acervo quando estiver pronto.');
} }
/**
* @return array<array-key, mixed>
*/
private function validateContent(Request $request): array private function validateContent(Request $request): array
{ {
return $request->validate(['body' => ['required', 'string', 'max:100000'], 'image_resource_id' => ['nullable', 'integer'], 'image_mode' => ['nullable', 'in:original,selected,none'], 'metadata_version' => ['required', 'integer', 'min:1']]); return $request->validate(['body' => ['required', 'string', 'max:100000'], 'image_resource_id' => ['nullable', 'integer'], 'image_mode' => ['nullable', 'in:original,selected,none'], 'metadata_version' => ['required', 'integer', 'min:1']]);
} }
/** private function edition(object $resource, array $data): array
* @param array<array-key, mixed> $data
* @return array<array-key, mixed>
*/
private function edition(\stdClass $resource, array $data): array
{ {
$reading = app(EditorialReading::class); $reading = app(\App\Services\EditorialReading::class);
$original = $reading->original($resource); $original = $reading->original($resource);
$mode = $data['image_mode'] ?? (! empty($data['image_resource_id']) ? 'selected' : 'none'); $mode = $data['image_mode'] ?? (!empty($data['image_resource_id']) ? 'selected' : 'none');
if ($mode === 'selected') { if ($mode === 'selected') abort_unless(!empty($data['image_resource_id']) && $reading->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
abort_unless(! empty($data['image_resource_id']) && $reading->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.'); return ['format' => 1, 'body' => trim($data['body']), 'image_mode' => $mode, 'image_resource_id' => $mode === 'selected' ? (int)$data['image_resource_id'] : null,
}
return ['format' => 1, 'body' => trim($data['body']), 'image_mode' => $mode, 'image_resource_id' => $mode === 'selected' ? (int) $data['image_resource_id'] : null,
'preserve_original_html' => $original && trim($data['body']) === $reading->plain($original['content_html'])]; 'preserve_original_html' => $original && trim($data['body']) === $reading->plain($original['content_html'])];
} }
public function preview(Request $request, int $id): Response public function preview(Request $request, int $id)
{ {
$this->installed(); $this->installed();
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first(); $resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404);
abort_unless($resource instanceof \stdClass, 404); return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource, null, true)])
$workflow = app(PortalEditorialWorkflow::class); ->toResponse($request)->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
if ($workflow->enabled()) {
$workflow->authorize(request()->user(), $id);
$resource = $workflow->workingResource($resource);
}
return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource, null, true)])
->toResponse($request), static function (Response $response): void {
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
});
} }
public function previewContent(Request $request, int $id): JsonResponse public function previewContent(Request $request, int $id)
{ {
$this->installed(); $this->installed();
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first(); $resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404);
abort_unless($resource instanceof \stdClass, 404);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->authorize($request->user(), $id);
$resource = $workflow->workingResource($resource);
}
$data = $this->validateContent($request); $data = $this->validateContent($request);
abort_unless((int) $resource->metadata_version === (int) $data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.'); abort_unless((int)$resource->metadata_version === (int)$data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.');
return response()->json(['article' => app(\App\Services\EditorialReading::class)->reading($resource, $this->edition($resource, $data), true)])
return response()->json(['article' => app(EditorialReading::class)->reading($resource, $this->edition($resource, $data), true)])
->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow'); ->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
} }
public function show(Request $request, int $id): Response public function show(Request $request, int $id)
{ {
$this->installed(); $this->installed();
$resource = app(EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']); $resource = app(\App\Services\EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']);
abort_unless($resource !== null, 404); abort_unless($resource, 404);
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource)])->toResponse($request)->header('Cache-Control', 'private, no-store');
return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource)])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
} }
} }
+44 -127
View File
@@ -11,45 +11,28 @@ use App\Models\Sector;
use App\Models\Skill; use App\Models\Skill;
use App\Models\TrainingCapacity; use App\Models\TrainingCapacity;
use App\Models\TrainingProvider; use App\Models\TrainingProvider;
use App\Models\User;
use App\Services\EducationAccess;
use App\Services\EducationDeclarationDrafts;
use App\Services\EducationDeclarationRules;
use App\Services\EducationGapCalculator; use App\Services\EducationGapCalculator;
use App\Services\EducationReviews;
use App\Services\EducationWorkQueue;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
class EducationController extends Controller class EducationController extends Controller
{ {
public function index(Request $request): \Symfony\Component\HttpFoundation\Response public function index(Request $request): Response
{ {
app(EducationAccess::class)->authorize($request->user()); $filters = $request->validate([
$filters = $this->filters($request); 'q' => ['nullable', 'string', 'max:150'],
if (($filters['scope'] ?? 'all') === 'review') { 'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
unset($filters['status']); 'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
} 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
$draftService = app(EducationDeclarationDrafts::class); 'demand_page' => ['nullable', 'integer', 'min:1'],
$draftsAvailable = $draftService->installed(); 'capacity_page' => ['nullable', 'integer', 'min:1'],
$selectedDraft = null; ]);
if (! empty($filters['draft'])) {
abort_unless($draftsAvailable, 404);
$kind = DB::table('education_declaration_drafts')->where('id', $filters['draft'])->where('owner_user_id', $request->user()->id)->value('kind');
abort_unless($kind !== null, 404);
$selectedDraft = $draftService->show($request->user(), $kind, $filters['draft']);
}
Inertia::encryptHistory();
$demands = LabourDemand::query(); $demands = LabourDemand::query();
$capacities = TrainingCapacity::query(); $capacities = TrainingCapacity::query();
$territoryIds = []; $territoryIds = [];
@@ -70,36 +53,20 @@ class EducationController extends Controller
} }
} }
// Viewing one's work must not remove existing territorial catalogue evidence.
$territorialCapacities = clone $capacities; $territorialCapacities = clone $capacities;
foreach ([$demands, $capacities] as $query) {
if (($filters['scope'] ?? 'all') === 'mine') {
$query->where('submitted_by', $request->user()->id);
} elseif (($filters['scope'] ?? 'all') === 'review') {
$query->where('submitted_by', '<>', $request->user()->id)->where('status', 'submitted')->where('valid_until', '>=', now()->toDateString());
}
}
$providers = TrainingProvider::where('status', '!=', 'inactive'); $providers = TrainingProvider::where('status', '!=', 'inactive');
$courses = Course::where('status', '!=', 'retired') $courses = Course::where('status', '!=', 'retired')
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id')); ->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'));
if ($territoryIds !== []) { if ($territoryIds !== []) {
// Provider and course location is evidenced by their submitted training capacity.
// Do not infer locations from provider names, addresses, or unrelated records.
$courses->whereIn('id', $territorialCapacities->select('course_id')); $courses->whereIn('id', $territorialCapacities->select('course_id'));
$providers->whereIn('id', (clone $courses)->select('provider_id')); $providers->whereIn('id', (clone $courses)->select('provider_id'));
} }
return tap(Inertia::render('education/index', [ return Inertia::render('education/index', [
'workQueue' => app(EducationWorkQueue::class)->summary($request->user()), 'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
'draftsAvailable' => $draftsAvailable, 'selectedDraft' => $selectedDraft, 'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
'ownDrafts' => $draftsAvailable ? DB::table('education_declaration_drafts')->where('owner_user_id', $request->user()->id)
->orderByDesc('updated_at')->orderBy('id')->paginate(20, ['id', 'kind', 'version_no', 'state', 'updated_at', 'payload'], 'draft_page')->withQueryString()
->through(function ($row) {
$payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->id, 'kind' => $row->kind, 'version_no' => (int) $row->version_no, 'state' => $row->state,
'updated_at' => CarbonImmutable::parse($row->updated_at)->toIso8601String(), 'label' => $payload['qualification'] ?? null];
}) : null,
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString()->through(fn (LabourDemand $row) => $this->listing($row, 'demands', $request, $filters)),
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString()->through(fn (TrainingCapacity $row) => $this->listing($row, 'capacities', $request, $filters)),
'filters' => $filters, 'filters' => $filters,
'indicators' => [ 'indicators' => [
'demand_records' => LabourDemand::count(), 'demand_records' => LabourDemand::count(),
@@ -116,63 +83,9 @@ class EducationController extends Controller
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality']) ->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']), ->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']),
'today' => now()->toDateString(), 'today' => now()->toDateString(),
])->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
/** @return array<string,mixed> */
private function filters(Request $request): array
{
return $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
'demand_page' => ['nullable', 'integer', 'min:1'],
'capacity_page' => ['nullable', 'integer', 'min:1'],
'draft' => ['nullable', 'uuid'], 'draft_page' => ['nullable', 'integer', 'min:1'],
'scope' => ['nullable', Rule::in(['all', 'mine', 'review'])],
]); ]);
} }
/** @param array<string,mixed> $filters
* @return array<string,mixed> */
private function listing(LabourDemand|TrainingCapacity $record, string $kind, Request $request, array $filters): array
{
$unavailable = $record->getAttribute('valid_until') < now()->toDateString() ? 'expired' : ((int) $record->getAttribute('submitted_by') === $request->user()->id ? 'self' : null);
return [...$record->toArray(), 'can_review' => $unavailable === null, 'review_unavailable' => $unavailable,
'details_url' => route('management.education.declaration', ['kind' => $kind, 'id' => $record->id, ...$filters], false)];
}
public function declaration(Request $request, string $kind, string $id): \Symfony\Component\HttpFoundation\Response
{
app(EducationAccess::class)->authorize($request->user());
$model = match ($kind) {
'demands' => LabourDemand::class,'capacities' => TrainingCapacity::class,default => abort(404)
};
$record = $model::findOrFail($id);
$filters = $this->filters($request);
$request->validate(['history_page' => ['nullable', 'integer', 'min:1']]);
$row = $this->listing($record, $kind, $request, $filters);
$row['review_context'] = app(EducationReviews::class)->context($request->user(), $record);
$row['submitted_by_name'] = User::whereKey($record->getAttribute('submitted_by'))->value('name');
$row['reviewed_by_name'] = $record->getAttribute('reviewed_by') ? User::whereKey($record->getAttribute('reviewed_by'))->value('name') : null;
$references = ['occupation' => Occupation::whereKey($record->getAttribute('occupation_id'))->value('name'),
'skill' => Skill::whereKey($record->getAttribute('skill_id'))->value('name'),
'location' => collect(app(InvestmentGeography::class)->path($record->getAttribute('geo_location_id'), true))->pluck('name')->implode(' › ')];
if ($kind === 'demands') {
$references['project'] = Project::whereKey($record->getAttribute('project_id'))->value('name');
} else {
$references['course'] = Course::whereKey($record->getAttribute('course_id'))->value('title');
}
Inertia::encryptHistory();
return tap(Inertia::render('education/declaration', ['declaration' => $row, 'kind' => $kind, 'actorId' => $request->user()->id, 'today' => now()->toDateString(),
'preparation' => app(EducationReviews::class)->preparation($request->user(), $record),
'history' => app(EducationReviews::class)->history($request->user(), $record),
'backUrl' => route('management.education.index', $filters, false), 'references' => $references])->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function catalog(Request $request, string $kind): RedirectResponse public function catalog(Request $request, string $kind): RedirectResponse
{ {
$model = match ($kind) { $model = match ($kind) {
@@ -199,41 +112,45 @@ class EducationController extends Controller
public function store(Request $request, string $kind): RedirectResponse public function store(Request $request, string $kind): RedirectResponse
{ {
$demand = $kind === 'demands'; $demand = $kind === 'demands';
$data = $request->validate(EducationDeclarationRules::forKind($kind)); $data = $request->validate([
'occupation_id' => ['required', 'uuid', Rule::exists('occupations', 'id')->whereNull('deleted_at')],
'skill_id' => ['required', 'uuid', Rule::exists('skills', 'id')->whereNull('deleted_at')],
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])],
$demand ? 'project_id' : 'course_id' => ['required', 'uuid', $demand
? Rule::exists('projects', 'id')->whereNull('deleted_at')
: Rule::exists('courses', 'id')->whereNull('deleted_at')->whereNot('status', 'retired')
->where(fn ($query) => $query->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id')))],
'employer' => [$demand ? 'required' : 'exclude', 'string', 'max:200'],
'qualification' => ['required', 'string', 'max:150'],
'quantity' => ['required', 'integer', 'min:1', 'max:10000000'],
'period_start' => ['required', 'date_format:Y-m-d'],
'period_end' => ['required', 'date_format:Y-m-d', 'after_or_equal:period_start'],
'source' => ['required', 'string', 'min:10', 'max:5000'],
'reference_date' => ['required', 'date_format:Y-m-d', 'before_or_equal:today'],
'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:reference_date'],
]);
PortalAudit::save($demand ? new LabourDemand : new TrainingCapacity, PortalAudit::save($demand ? new LabourDemand : new TrainingCapacity,
[...$data, 'submitted_by' => $request->user()->id, 'status' => 'submitted'], $request->user()->id); [...$data, 'submitted_by' => $request->user()->id, 'status' => 'submitted'], $request->user()->id);
return back()->with('success', 'Declaração registada para revisão independente.'); return back()->with('success', 'Declaração registada para revisão independente.');
} }
public function reviewPreparation(Request $request, string $kind, string $id): JsonResponse
{
$service = app(EducationReviews::class);
app(EducationAccess::class)->authorize($request->user());
if ($request->isMethod('POST')) {
// Like CRM preparations, retain partial text before business validation.
$input = json_decode($request->getContent(), true);
if (! is_array($input)) {
throw ValidationException::withMessages(['snapshot' => 'A preparação deve ser enviada em JSON.']);
}
$view = $service->savePreparation($request->user(), $kind, $id, $input);
} else {
$expected = $request->validate(['actor_id' => 'sometimes|required|integer|min:1']);
abort_if(isset($expected['actor_id']) && (int) $expected['actor_id'] !== $request->user()->id, 409, 'A conta mudou. Actualize a entrada antes de recuperar.');
$record = match ($kind) {
'demands' => LabourDemand::findOrFail($id), 'capacities' => TrainingCapacity::findOrFail($id), default => abort(404)
};
$view = $service->preparation($request->user(), $record);
}
return response()->json($view)->header('Cache-Control', 'private, no-store');
}
public function review(Request $request, string $kind, string $id): RedirectResponse public function review(Request $request, string $kind, string $id): RedirectResponse
{ {
$result = app(EducationReviews::class)->decide($request->user(), $kind, $id, $request->all()); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'],
'status' => ['required', 'in:reviewed,rejected'], 'review_reason' => ['required', 'string', 'min:15', 'max:3000']]);
$model = $kind === 'demands' ? LabourDemand::class : TrainingCapacity::class;
DB::transaction(function () use ($model, $id, $data, $request) {
$record = $model::whereKey($id)->lockForUpdate()->firstOrFail();
abort_if((int) $record->getAttribute('submitted_by') === $request->user()->id, 403, 'Outro gestor deve efectuar a revisão.');
abort_if((int) $record->getAttribute('version_no') !== $data['version_no'], 409, 'A versão foi alterada. Recarregue antes de decidir.');
abort_if($record->getAttribute('valid_until') < now()->toDateString(), 422, 'A fonte expirou.');
PortalAudit::save($record, [...$data, 'version_no' => $data['version_no'] + 1,
'reviewed_by' => $request->user()->id], $request->user()->id);
});
return back()->with('success', $result['replayed'] ? 'A revisão já tinha sido registada.' : 'Revisão interna registada; não substitui decisão institucional.'); return back()->with('success', 'Revisão interna registada; não substitui decisão institucional.');
} }
public function analysis(string $id, EducationGapCalculator $calculator): Response public function analysis(string $id, EducationGapCalculator $calculator): Response
@@ -1,34 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\EducationDeclarationDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class EducationDeclarationDraftController extends Controller
{
public function index(Request $request, string $kind, EducationDeclarationDrafts $drafts): JsonResponse
{
$drafts->guard($request->user(), $kind);
return response()->json(DB::table('education_declaration_drafts')->where('owner_user_id', $request->user()->id)->where('kind', $kind)
->orderByDesc('updated_at')->orderBy('id')->paginate(20, ['id', 'kind', 'version_no', 'state', 'updated_at']))->header('Cache-Control', 'private, no-store');
}
public function show(Request $request, string $kind, string $id, EducationDeclarationDrafts $drafts): JsonResponse
{
return response()->json($drafts->show($request->user(), $kind, $id))->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, string $kind, EducationDeclarationDrafts $drafts, ?string $id = null): JsonResponse
{
return response()->json($drafts->save($request->user(), $kind, $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function submit(Request $request, string $kind, string $id, EducationDeclarationDrafts $drafts): JsonResponse
{
return response()->json($drafts->submit($request->user(), $kind, $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
}
@@ -7,12 +7,10 @@ use App\Models\ExistingBusinessObject;
use App\Models\Sector; use App\Models\Sector;
use App\Policies\ExistingBusinessPolicy; use App\Policies\ExistingBusinessPolicy;
use App\Services\BusinessFormSchema; use App\Services\BusinessFormSchema;
use App\Services\BusinessPointPublication;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts; use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake; use App\Services\ExistingBusinessIntake;
use App\Services\ExistingBusinessObjects; use App\Services\ExistingBusinessObjects;
use App\Services\ExistingBusinessProfileCategory;
use App\Services\ExistingBusinessVerification; use App\Services\ExistingBusinessVerification;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\Mne\BusinessRepository; use App\Services\Mne\BusinessRepository;
@@ -69,8 +67,7 @@ class ExistingBusinessController extends Controller
$business = app(BusinessRepository::class)->find($id); $business = app(BusinessRepository::class)->find($id);
Gate::authorize('view', $business); Gate::authorize('view', $business);
app(CompatibilityShadow::class)->schedule($id); app(CompatibilityShadow::class)->schedule($id);
$props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business), $props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => \App\Services\ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
'pointPublicationAvailable' => app(BusinessPointPublication::class)->available(), 'registeredLocation' => app(BusinessPointPublication::class)->registered($business),
'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business), 'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
'canTriage' => $request->user()->can('triage', $business), 'canTriage' => $request->user()->can('triage', $business),
'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business), 'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business),
@@ -1,241 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Sector;
use App\Services\DocumentManagementAccess;
use App\Services\FundingDossierPreparation;
use App\Services\FundingInstitutionClassification;
use App\Services\FundingInstitutionDetails;
use App\Services\FundingProgrammeReferences;
use App\Services\FundingProgrammes;
use App\Services\InvestmentGeography;
use App\Services\InvestorContext;
use App\Services\InvestorPath;
use App\Services\Procurement;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class FundingProgrammeController extends Controller
{
public function reference(Request $request, string $code, FundingProgrammeReferences $references): \Symfony\Component\HttpFoundation\Response
{
$plan = $references->preview($request->user(), $code);
if ($request->expectsJson()) {
return response()->json($plan)->header('Cache-Control', 'private, no-store');
}
return tap(Inertia::render('funding/reference', ['reference' => $plan])->toResponse($request),
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function prepareReference(Request $request, string $code, FundingProgrammeReferences $references): RedirectResponse|JsonResponse
{
$data = $request->validate(['hash' => 'required|string|size:64']);
$result = $references->prepare($request->user(), $code, $data['hash']);
if ($request->expectsJson()) {
$reference = $references->preview($request->user(), $code);
$service = app(FundingProgrammes::class);
$matches = $service->matchesReference($request->user(), $result['id'], $reference['draft']);
$publication = $matches ? $service->preview($request->user(), $result['id']) : null;
return response()->json([...$result, 'matches_reference' => $matches, 'publication' => $publication ? ['version' => $publication['version'], 'hash' => $publication['hash']] : null])->header('Cache-Control', 'private, no-store');
}
return redirect('/management/fundos/'.$result['id'])->with('success', $result['created']
? 'Rascunho preparado com as fontes indicadas. Ainda não está publicado.'
: 'Esta referência já tem ficha. Aberta a versão existente, sem substituir alterações ou acrescentar chamadas.');
}
public function assisted(Request $request, FundingProgrammeReferences $references): \Symfony\Component\HttpFoundation\Response
{
return tap(Inertia::render('funding/assisted', ['batch' => $references->assisted($request->user())])->toResponse($request), static fn ($response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function manage(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
{
$service->guard($request->user());
$items = DB::table('funding_programmes')->orderByDesc('updated_at')->paginate(20);
$items->through(fn ($row) => ['id' => $row->id, 'title' => json_decode($row->draft, true)['title'] ?? 'Rascunho sem título',
'version' => (int) $row->version, 'published' => $row->published_version_id !== null]);
return tap(Inertia::render('funding/manage', ['items' => $items, 'references' => app(FundingProgrammeReferences::class)->choices($request->user())])->toResponse($request),
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function edit(Request $request, FundingProgrammes $service, ?string $id = null): \Symfony\Component\HttpFoundation\Response
{
$service->guard($request->user());
$row = $id ? DB::table('funding_programmes')->where('id', $id)->firstOrFail() : null;
$editable = app(DocumentManagementAccess::class)->documents($request->user())->select('id');
$references = ['organizations' => DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->get(['id', 'name']),
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name']),
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name']),
'documents' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->whereNull('d.deleted_at')->where(fn ($q) => $q->whereIn('d.id', $editable)
->orWhereIn('r.id', app(Procurement::class)->publicDocuments()->select('r.id')))
->orderBy('r.title')->get(['r.id', 'r.title'])];
return tap(Inertia::render('funding/edit', ['record' => $row ? ['id' => $row->id, 'code' => $row->code, 'version' => (int) $row->version,
'draft' => json_decode($row->draft, true), 'published' => $row->published_version_id !== null] : null,
'references' => Arr::only($references, ['organizations', 'sectors', 'territories', 'documents']),
'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'institutionNatures' => FundingInstitutionDetails::NATURES, 'needs' => FundingInstitutionDetails::NEEDS,
'kinds' => FundingProgrammes::KINDS, 'windowModes' => FundingProgrammes::WINDOWS,
'history' => $id ? DB::table('funding_programme_versions')->where('programme_id', $id)->orderByDesc('version')->get(['version', 'reason', 'published_at']) : []])->toResponse($request),
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function save(Request $request, FundingProgrammes $service, ?string $id = null): RedirectResponse
{
$id = $service->save($request->user(), $id, $request->all());
return redirect('/management/fundos/'.$id)->with('success', 'Rascunho guardado. Confira a prévia antes de publicar.');
}
public function preview(Request $request, string $id, FundingProgrammes $service): JsonResponse
{
return response()->json($service->preview($request->user(), $id))->header('Cache-Control', 'private, no-store');
}
public function publish(Request $request, string $id, FundingProgrammes $service): RedirectResponse|JsonResponse
{
$versionId = $service->publish($request->user(), $id, $request->all());
if ($request->expectsJson()) {
return response()->json(['id' => $id, 'version_id' => $versionId, 'published' => true])->header('Cache-Control', 'private, no-store');
}
return back()->with('success', 'Nova versão publicada. Os interesses anteriores conservam a versão guardada.');
}
public function withdraw(Request $request, string $id, FundingProgrammes $service): RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'reason' => 'required|string|min:20|max:2000']);
$service->withdraw($request->user(), $id, $data['version'], $data['reason']);
return back()->with('success', 'Publicação retirada. Versões e interesses preservados.');
}
public function saved(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
{
abort_unless($service->installed(), 404);
$items = DB::table('funding_programme_interests as i')
->join('funding_programme_versions as v', fn ($join) => $join->on('v.id', '=', 'i.version_id')->on('v.programme_id', '=', 'i.programme_id'))
->join('funding_programmes as p', 'p.id', '=', 'i.programme_id')
->where('i.user_id', $request->user()->id)->whereNull('i.removed_at')
->orderByDesc('i.saved_at')->orderBy('i.programme_id')
->paginate(20, ['i.programme_id', 'i.saved_at', 'v.version', 'v.payload', 'p.published_version_id'])->withQueryString();
$items->through(function ($row) use ($service) {
$current = $service->publicQuery()->where('p.id', $row->programme_id)->first(['v.version']);
$snapshot = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->programme_id, 'title' => $snapshot['title'], 'saved_version' => (int) $row->version,
'saved_at' => $row->saved_at, 'available' => $current !== null, 'current_version' => $current ? (int) $current->version : null];
});
return tap(Inertia::render('funding/saved', ['items' => $items])->toResponse($request),
static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function index(Request $request, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
{
$filters = $request->validate(['q' => 'nullable|string|max:150', 'kind' => 'nullable|in:institution,fund,programme,incentive',
'category' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::CATEGORIES))], 'institution_type' => ['nullable', Rule::in(array_keys(FundingInstitutionClassification::TYPES))], 'location_mode' => 'nullable|in:physical,coverage', 'include_historical' => 'nullable|boolean',
'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))],
'context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150',
'territory' => ['nullable', 'uuid', Rule::in(app(InvestmentGeography::class)->publicReferences()->pluck('id')->all())],
'sector' => ['nullable', 'integer', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
'certainty' => 'nullable|in:include_unknown,documented', 'state' => 'nullable|in:open,closed,upcoming,unknown']);
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
$dossier = $request->user() && ! empty($filters['context']) ? app(FundingDossierPreparation::class)->context($request->user(), $filters['context'], $filters['opportunity'] ?? null) : null;
if ($dossier && empty($filters['territory']) && $dossier['territory']) {
$filters['territory'] = $dossier['territory'];
}
$query = $service->catalogueQuery($filters);
if (! empty($filters['q'])) {
$term = '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['q']).'%';
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ilike ?", [$term])->orWhereRaw("v.payload->>'summary' ilike ?", [$term])->orWhereRaw("(v.payload->'aliases')::text ilike ?", [$term])->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) l WHERE l->>'label' ilike ? OR l->>'address' ilike ? OR l->>'location_text' ilike ?)", [$term, $term, $term]));
}
if (! empty($filters['kind'])) {
$query->whereRaw("v.payload->>'kind' = ?", [$filters['kind']]);
}
$items = $query->orderByDesc('v.published_at')->orderBy('p.id')->paginate(20, ['p.id', 'p.code', 'v.id as version_id', 'v.version', 'v.payload', 'v.published_at'])->withQueryString();
$items->through(function ($row) use ($service, $filters) {
$record = $service->present($row);
return [...$record, 'filter_explanation' => $service->filterExplanation($record, $filters)];
});
return tap(Inertia::render('funding/index', ['items' => $items, 'filters' => $filters, 'categories' => FundingInstitutionClassification::CATEGORIES, 'institutionTypes' => FundingInstitutionClassification::TYPES, 'kinds' => FundingProgrammes::KINDS, 'needs' => FundingInstitutionDetails::NEEDS, 'dossier' => $dossier,
'options' => ['territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name']),
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['id', 'name'])], 'management' => $service->access($request->user())])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function show(Request $request, string $id, FundingProgrammes $service): \Symfony\Component\HttpFoundation\Response
{
$request->validate(['context' => 'nullable|uuid', 'opportunity' => 'nullable|string|max:150', 'need' => ['nullable', Rule::in(array_keys(FundingInstitutionDetails::NEEDS))], 'territory' => 'nullable|uuid', 'category' => 'nullable|string|max:80', 'institution_type' => 'nullable|string|max:80', 'location_mode' => 'nullable|in:physical,coverage']);
$dossier = $request->user() && $request->filled('context') ? app(FundingDossierPreparation::class)->context($request->user(), $request->string('context')->toString(), $request->query('opportunity')) : null;
if ($dossier && ! $request->filled('territory') && $dossier['territory']) {
$request->merge(['territory' => $dossier['territory']]);
}
$record = $service->published($id);
$destinations = [];
if ($request->user()) {
foreach (app(InvestorPath::class)->contexts($request->user()) as $item) {
$case = app(VirtualDesk::class)->case($request->user(), $item['id']);
$state = app(InvestorContext::class)->state($case);
$choices = $state['opportunity_choices'] ?? [];
if ($choices === []) {
$destinations[] = ['id' => $item['id'], 'opportunity' => null, 'title' => $item['title']];
}
foreach ($choices as $choice) {
$destinations[] = ['id' => $item['id'], 'opportunity' => $choice['opportunity']['key'], 'title' => $item['title'].' — '.$choice['opportunity']['title']];
}
}
}
$saved = $request->user() ? DB::table('funding_programme_interests as i')->join('funding_programme_versions as v', 'v.id', '=', 'i.version_id')
->where('i.programme_id', $id)->where('i.user_id', $request->user()->id)->whereNull('i.removed_at')->first(['v.version', 'i.saved_at']) : null;
return tap(Inertia::render('funding/show', ['record' => $record, 'saved' => $saved, 'authenticated' => $request->user() !== null, 'dossier' => $dossier, 'selection' => [...$request->only(['need', 'territory', 'context', 'opportunity', 'category', 'institution_type', 'location_mode']), 'territory_label' => app(InvestmentGeography::class)->publicReferences()->where('id', $request->query('territory'))->value('name') ?? 'Local por seleccionar'], 'needs' => FundingInstitutionDetails::NEEDS, 'destinations' => $destinations])
->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function dossier(Request $request, string $id, FundingDossierPreparation $service): JsonResponse
{
return response()->json($service->save($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function assess(Request $request, string $id, FundingProgrammes $service): JsonResponse
{
return response()->json($service->assess($id, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function saveInterest(Request $request, string $id, FundingProgrammes $service): RedirectResponse
{
$data = $request->validate(['version_id' => 'required|uuid', 'consent' => 'required|accepted']);
$service->saveInterest($request->user(), $id, $data['version_id'], true);
return back()->with('success', 'Programa guardado na sua área privada. Consulte a versão indicada na ficha.');
}
public function removeInterest(Request $request, string $id, FundingProgrammes $service): RedirectResponse
{
abort_unless($service->installed(), 404);
$service->removeInterest($request->user(), $id);
return back()->with('success', 'Interesse removido da sua lista.');
}
public function download(string $id, string $version, int $resource, FundingProgrammes $service): Response|BinaryFileResponse
{
return $service->download($id, $version, $resource);
}
}
@@ -1,319 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\EditorialReading;
use App\Services\FederatedSearch;
use App\Services\FundingInstitutionDetails;
use App\Services\FundingProgrammes;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use App\Services\LegacyContent;
use App\Services\SearchTerms;
use App\Services\SectorOrganization;
use App\Services\SectorResourceReferences;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class GlobalSearchController extends Controller
{
public function __invoke(Request $request, InvestmentCatalogue $catalogue, SearchTerms $search, FederatedSearch $federated): Response
{
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'], 'category' => ['nullable', 'in:all,physical,financial']]);
$original = trim($input['q'] ?? '');
$intent = $search->requestIntent($original);
if (isset($input['category']) && ($intent === null || $intent['type'] === 'products')) {
$intent = ['type' => 'products', 'label' => 'Produtos e localização', 'kind' => null,
'filter' => $intent['filter'] ?? $original,
'category' => match ($input['category']) {
'physical' => 'Produtos e localizações', 'financial' => 'Produtos financeiros', default => null,
}];
}
$term = $intent['filter'] ?? $original;
$tokens = $search->tokens($term);
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
if ($tokens !== [] || $intent !== null) {
$factories = [
'Produtos e localizações' => function () use ($search, $tokens, $term) {
try {
return $this->productReferences($search, $tokens, $term);
} catch (\RuntimeException $exception) {
throw new HttpException(503, 'Referências de produtos temporariamente indisponíveis.', $exception);
}
},
'Produtos financeiros' => function () use ($search, $tokens, $term) {
$service = app(FundingProgrammes::class);
if (! $service->installed()) {
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
}
$query = $service->catalogueQuery([])->select('p.id', 'v.payload')->selectRaw("v.payload->>'title' AS title");
foreach ($tokens as $token) {
$query->where(function ($match) use ($search, $token) {
$search->orFields($match, ["(v.payload->>'title')",
"(SELECT string_agg(product->>'title', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product)",
"(SELECT string_agg(place->>'location_text', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) place)",
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.locations[*].territory_path[*]') node)",
"(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.products[*].territory_paths[*][*]') node)"], $token);
foreach (FundingInstitutionDetails::NEEDS as $code => $label) {
$text = in_array($code, ['investment', 'working_capital'], true) ? 'Financiamento '.$label : $label;
if ($search->matches($text, [$token])) {
$match->orWhereJsonContains('v.payload->institution_services', $code)->orWhereJsonContains('v.payload->funding_needs', $code)
->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product WHERE product->'needs' @> ?::jsonb)", [json_encode([$code], JSON_THROW_ON_ERROR)]);
}
}
foreach (FundingInstitutionDetails::NATURES as $code => $label) {
if ($search->matches($label, [$token])) {
$match->orWhere('v.payload->institution_nature', $code);
}
}
});
}
return $this->source($query, $search, $term, $tokens, "(v.payload->>'title')", 'p.id', function ($row) {
$p = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
$products = array_values(array_unique(array_filter(array_column($p['products'] ?? [], 'title'))));
$services = array_values(array_intersect_key(FundingInstitutionDetails::NEEDS, array_flip($p['institution_services'] ?? [])));
$places = [];
foreach ($p['locations'] ?? [] as $location) {
if (! empty($location['location_text'])) {
$places[] = $location['location_text'];
}
foreach ($location['territory_path'] ?? [] as $node) {
if ($node['type'] !== 'country') {
$places[] = $node['name'];
}
}
}
$national = array_any($p['products'] ?? [], fn ($product) => ($product['coverage'] ?? '') === 'national');
return ['title' => $row->title, 'description' => implode(' · ', $products ?: $services) ?: 'Instituição financeira.',
'location' => $places === [] ? null : 'Presença física: '.implode(' · ', array_unique($places)),
'coverage' => $national ? 'Existe produto com cobertura nacional indicada; consulte as condições.' : 'Cobertura financeira: consultar cada produto.',
'url' => '/fundos/'.$row->id];
});
},
'Páginas e serviços' => function () use ($search, $tokens, $term) {
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)));
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url']));
return ['count' => count($pages), 'fetch' => fn ($offset, $limit) => array_slice($pages, $offset, $limit)];
},
'Oportunidades' => function () use ($catalogue, $search, $tokens, $term) {
$query = $catalogue->query(['include_national' => true]);
foreach ($tokens as $token) {
$query->where(function ($match) use ($catalogue, $search, $token) {
$search->orFields($match, ['opportunities.title', 'opportunities.summary', 'opportunities.description', 'opportunities.reference', 'opportunities.province'], $token);
$match->orWhereHas('sector', fn ($sector) => $search->constrain($sector, ['name'], [$token]));
$match->orWhereExists(fn ($location) => $location->selectRaw('1')->from('geo_locations as search_location')
->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id'))
->where(fn ($name) => $search->orFields($name, ['search_location.name'], $token))
->where(fn ($direct) => $direct->whereColumn('search_location.id', 'opportunities.primary_territory_id')
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations as search_link')
->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereColumn('search_link.geo_location_id', 'search_location.id'))));
if (preg_match('/^[a-z0-9]+$/', $token)) {
$match->orWhereIn('opportunities.id', $catalogue->query(['q' => $token, 'include_national' => true])->reorder()->select('opportunities.id'));
}
});
}
return $this->source($query, $search, $term, $tokens, 'opportunities.title', 'opportunities.id',
fn ($r) => ['title' => $r->title, 'description' => $r->summary, 'url' => '/oportunidades/'.rawurlencode($r->slug)]);
},
'Negócios' => function () use ($search, $tokens, $term) {
$query = ExistingBusiness::query()->published();
$search->constrain($query, ['public_title', 'activity_code', 'objective'], $tokens);
return $this->source($query, $search, $term, $tokens, 'public_title', 'existing_businesses.id',
fn ($r) => ['title' => $r->public_title, 'description' => $r->objective, 'url' => '/negocios/'.rawurlencode($r->public_slug)]);
},
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term, $intent) {
$query = InvestmentPortfolioController::visible();
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
if ($intent !== null && $intent['kind'] !== null) {
[$column, $table] = ['corridor' => ['corridor_id', 'development_corridors'], 'cluster' => ['cluster_id', 'economic_clusters'], 'project' => ['project_id', 'projects']][$intent['kind']];
$query->whereNotNull($column)->whereExists(fn ($entity) => $entity->selectRaw('1')->from($table)
->whereColumn($table.'.id', 'investment_dossiers.'.$column)->whereNull($table.'.deleted_at'));
// The existing unique entity key already prevents duplicate dossiers.
}
return $this->source($query, $search, $term, $tokens, 'investment_dossiers.title', 'investment_dossiers.id',
fn ($r) => ['title' => $r->title, 'description' => $r->corridor_id ? 'Corredor' : ($r->cluster_id ? 'Cluster' : 'Projecto âncora'), 'url' => '/carteira/'.$r->id]);
},
'Documentos e legislação' => function () use ($search, $tokens, $term) {
$query = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
$search->constrain($query, ['r.title', 'r.description', 'r.document_number', 'd.issuer', 'r.source_reference', 'r.category', 'r.topic'], $tokens);
$query->select('r.id', 'r.title', 'r.description');
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
fn ($r) => ['title' => $r->title, 'description' => $r->description, 'url' => '/documentos/'.$r->id]);
},
'Artigos' => function () use ($search, $tokens, $term) {
if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
}
$query = app(EditorialReading::class)->publicResources()
->whereRaw("nullif(btrim(r.editorial_content->>'body'), '') is not null");
$search->constrain($query, ['r.title', "(r.editorial_content->>'body')"], $tokens);
$query->select('r.id', 'r.title')->selectRaw("r.editorial_content->>'body' as body");
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
fn ($r) => ['title' => $r->title, 'description' => Str::limit(trim(strip_tags($r->body)), 220), 'url' => '/artigos/'.$r->id]);
},
'Sectores' => function () use ($search, $tokens, $term) {
$query = Sector::query()->where('is_active', true)->whereNotNull('published_at');
$search->constrain($query, ['name', 'description', 'short_description'], $tokens);
return $this->source($query, $search, $term, $tokens, 'name', 'sectors.id',
fn ($r) => ['title' => $r->name, 'description' => $r->short_description, 'url' => SectorOrganization::href($r->slug)]);
},
'Localizações' => function () use ($search, $tokens, $term) {
$query = app(InvestmentGeography::class)->publicReferences()
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality']);
$search->constrain($query, ['geo_locations.name', 'geo_locations.source_code'], $tokens);
$query->select('geo_locations.id', 'geo_locations.name', 'geo_locations.location_type');
return $this->source($query, $search, $term, $tokens, 'geo_locations.name', 'geo_locations.id',
fn ($r) => ['title' => $r->name, 'description' => ['province' => 'Província', 'district' => 'Distrito', 'admin_post' => 'Posto administrativo', 'locality' => 'Localidade'][$r->location_type],
'url' => '/oportunidades?'.http_build_query([$r->location_type.'_id' => $r->id])]);
},
'Zonas de investimento' => function () use ($search, $tokens, $term) {
$query = app(InvestmentZones::class)->visible();
$search->constrain($query, ['name', 'code'], $tokens);
return $this->source($query, $search, $term, $tokens, 'name', 'investment_zones.id',
fn ($r) => ['title' => $r->name, 'description' => $r->code, 'url' => '/zonas/'.$r->id]);
},
'Parcerias' => function () use ($search, $tokens, $term) {
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
$search->constrain($query, ['p.title', 'p.sector', 'p.province'], $tokens);
$query->select('p.id', 'p.title', 'p.sector', 'p.province');
return $this->source($query, $search, $term, $tokens, 'p.title', 'p.id',
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->id]);
},
];
if ($intent !== null) {
$categories = $intent['category'] === null ? ['Produtos e localizações', 'Produtos financeiros'] : [$intent['category']];
$factories = array_intersect_key($factories, array_flip($categories));
}
$results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
fn ($item) => $search->score($item['title'], $term, $tokens));
}
$answer = $intent === null ? null : ['type' => $intent['type'], 'label' => $intent['label'], 'filter' => $term,
'count' => $results['unavailable'] === [] ? $results['total'] : null];
if ($answer !== null && $intent['type'] === 'products') {
$answer['category'] = match ($intent['category']) {
'Produtos e localizações' => 'physical', 'Produtos financeiros' => 'financial', default => 'all',
};
}
return Inertia::render('portal/search', ['query' => $original, 'results' => $results, 'answer' => $answer]);
}
/** @param list<string> $tokens
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
*/
private function productReferences(SearchTerms $search, array $tokens, string $term): array
{
$rows = [];
foreach (['mineracao', 'energia-especifica'] as $sector) {
if (! Sector::where('slug', $sector)->where('is_active', true)->whereNotNull('published_at')->exists()) {
continue;
}
$data = app(SectorResourceReferences::class)->view($sector);
foreach ($data['groups'] as $group) {
$locations = array_values(array_filter($group['location_labels'], fn ($name) => $search->matches($term, $search->tokens($name))));
$rows[] = ['title' => $group['name'], 'description' => $sector === 'mineracao' ? 'Referência mineral; não identifica uma concessão ou produção disponível.' : 'Segmento energético e cadeia de valor.',
'location' => $group['locations_text'], 'url' => '/sectores/'.$sector.'?'.http_build_query(['resource_group' => $group['id'],
...(count($locations) === 1 ? ['resource_location' => $locations[0]] : [])])];
}
}
if (app(LegacyContent::class)->available() && Sector::where('slug', 'agronegocio')->where('is_active', true)->whereNotNull('published_at')->exists()) {
try {
app(LegacyContent::class)->article('agronegocio', false);
$entries = app(CorridorAgribusinessOpportunities::class)->entries();
$published = Opportunity::published()->whereIn('management_creation_key', array_column($entries, 'key'))->pluck('slug', 'management_creation_key');
foreach ($entries as $entry) {
if (! isset($published[$entry['key']])) {
continue;
}
$rows[] = ['title' => $entry['chain'], 'description' => 'Cadeia agrícola: '.$entry['summary'], 'location' => 'Enquadramento territorial: '.$entry['corridor'],
'url' => '/oportunidades/'.rawurlencode($published[$entry['key']])];
}
} catch (HttpException $exception) {
if ($exception->getStatusCode() !== 404) {
throw $exception;
}
}
}
$rows = array_values(array_filter($rows, fn ($row) => $search->matches(implode(' ', $row), $tokens)));
usort($rows, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens) ?: strcmp($a['title'], $b['title']));
return ['count' => count($rows), 'fetch' => fn ($offset, $limit) => array_slice($rows, $offset, $limit)];
}
/** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
* @param list<string> $tokens
* @param callable(mixed):array<string,mixed> $present
* @param literal-string $title
* @param literal-string $id
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
*/
private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
{
$count = (clone $query)->reorder()->count();
$query->reorder();
$search->orderByRelevance($query, $title, $term, $tokens, $id);
return ['count' => $count, 'fetch' => fn ($offset, $limit) => (clone $query)->offset($offset)->limit($limit)->get()->map($present)->all()];
}
/** Public service entry points and terms used to find them.
* @return list<array{title:string,description:string,url:string}>
*/
private function pages(): array
{
return [
['title' => 'Licenciamento da empresa', 'description' => 'Roteiro de constituição e licenciamento conforme a actividade e localização.', 'url' => '/como-investir#planeamento'],
['title' => 'Preparar licenciamento no dossier', 'description' => 'Continuar a preparação na etapa de licenciamento prévio.', 'url' => '/investimentos/iniciar?step=S08'],
['title' => 'Como investir em Moçambique', 'description' => 'Procedimentos e etapas para investir', 'url' => '/como-investir'],
['title' => 'Iniciar ou continuar investimento', 'description' => 'Preparar uma ideia e dossier de investimento', 'url' => '/investimentos/iniciar'],
['title' => 'Oportunidades de investimento', 'description' => 'Pesquisar por sector, localização e maturidade', 'url' => '/oportunidades'],
['title' => 'Carteira de investimento', 'description' => 'Projectos âncora, clusters e corredores', 'url' => '/carteira'],
['title' => 'Negócios existentes', 'description' => 'Compra, venda e participação em negócios', 'url' => '/negocios'],
['title' => 'Sectores de investimento', 'description' => 'Actividades económicas e sectores prioritários', 'url' => '/sectores'],
['title' => 'Zonas de investimento', 'description' => 'Zonas económicas especiais, ZEE, ZFI e parques industriais', 'url' => '/zonas'],
['title' => 'Contextos de investimento', 'description' => 'Formas de iniciar e preparar um investimento', 'url' => '/contextos-de-investimento'],
['title' => 'Documentos e formulários', 'description' => 'Acervo documental e formulários para investidores e empresas', 'url' => '/documentos'],
['title' => 'Formulário do investidor', 'description' => 'Dados e contacto para investir', 'url' => '/formularios/investidor'],
['title' => 'Formulário de empresa', 'description' => 'Dados e contacto para empresas', 'url' => '/formularios/empresa'],
['title' => 'Legislação e incentivos', 'description' => 'Leis, decretos, benefícios e enquadramento legal', 'url' => '/legislacao'],
['title' => 'Simulador fiscal', 'description' => 'Simular impostos e cenários fiscais de investimento', 'url' => '/simulador-fiscal'],
['title' => 'Formulários fiscais', 'description' => 'Preparação dos formulários IVA e IRPC', 'url' => '/formularios/fiscais'],
['title' => 'Terra e DUAT', 'description' => 'Apoio ao uso e aproveitamento da terra', 'url' => '/terra-duat'],
['title' => 'Bolsa de parcerias', 'description' => 'Encontrar parceiros e propostas', 'url' => '/parcerias'],
['title' => 'Apoio ao investidor', 'description' => 'Contactos e pedido de apoio', 'url' => '/apoio'],
['title' => 'Educação para investimento', 'description' => 'Conteúdos educativos e orientação', 'url' => '/educacao'],
['title' => 'Sobre o InvesteMoz', 'description' => 'Informação institucional do portal', 'url' => '/sobre'],
['title' => 'Contacto', 'description' => 'Contactar o portal InvesteMoz', 'url' => '/contacto'],
['title' => 'Política de privacidade', 'description' => 'Privacidade e tratamento de dados pessoais', 'url' => '/politica-de-privacidade'],
['title' => 'Termos de uso', 'description' => 'Condições de utilização do portal', 'url' => '/termos-de-uso'],
];
}
}
@@ -1,20 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentOpportunityAlerts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class InvestmentAlertPreferencesController extends Controller
{
public function save(Request $request, InvestmentOpportunityAlerts $alerts): JsonResponse|RedirectResponse
{
$preferences = $alerts->save($request->user(), $request->all());
return $request->expectsJson() ? response()->json($preferences) : back()->with('success', $preferences['enabled']
? 'Preferências guardadas. O acompanhamento considera novas disponibilizações nos catálogos escolhidos.'
: 'Alertas dos catálogos cancelados. As notificações anteriores foram conservadas.');
}
}
@@ -3,32 +3,25 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\InvestmentComplements; use App\Services\InvestmentComplements;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class InvestmentComplementController extends Controller final class InvestmentComplementController extends Controller
{ {
public function index(Request $request, InvestmentComplements $service): JsonResponse public function index(Request $request, InvestmentComplements $service)
{ {
$data = $request->validate(['origin' => [Rule::requiredIf(! $request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid', $data = $request->validate(['origin' => [\Illuminate\Validation\Rule::requiredIf(!$request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid',
'contexts' => 'sometimes|array|max:100', 'contexts.*' => 'array:origin,territory_id', 'contexts.*.origin' => 'required|string|max:100', 'contexts.*.territory_id' => 'nullable|uuid']); 'contexts' => 'sometimes|array|max:100', 'contexts.*' => 'array:origin,territory_id', 'contexts.*.origin' => 'required|string|max:100', 'contexts.*.territory_id' => 'nullable|uuid']);
$items = []; $items = [];
$issues = []; $issues = [];
foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) { foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) {
try { try {
$items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)]; $items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)];
} catch (ModelNotFoundException|HttpException $e) { } catch (\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException $e) {
$status = $e instanceof HttpException ? $e->getStatusCode() : 404; $status = $e instanceof \Symfony\Component\HttpKernel\Exception\HttpException ? $e->getStatusCode() : 404;
if (! isset($data['contexts']) || $status !== 404) { if (!isset($data['contexts']) || $status !== 404) throw $e;
throw $e;
}
$issues[] = ['origin' => $context['origin'], 'message' => 'Uma referência escolhida deixou de estar disponível para consulta pública. A relação guardada conserva-se no dossier; os outros complementos continuam disponíveis.']; $issues[] = ['origin' => $context['origin'], 'message' => 'Uma referência escolhida deixou de estar disponível para consulta pública. A relação guardada conserva-se no dossier; os outros complementos continuam disponíveis.'];
} }
} }
return response()->json(isset($data['contexts']) ? ['items' => $items, 'issues' => $issues] : $items)->header('Cache-Control', 'private, no-store'); return response()->json(isset($data['contexts']) ? ['items' => $items, 'issues' => $issues] : $items)->header('Cache-Control', 'private, no-store');
} }
} }
@@ -4,18 +4,9 @@ namespace App\Http\Controllers;
use App\Models\InvestmentDossier; use App\Models\InvestmentDossier;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentCatalogue; use App\Services\InvestmentCatalogue;
use App\Services\InvestmentComplements;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentMapIndicators;
use App\Services\InvestmentZoneOperationalDetails;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
use App\Services\OpportunityCorridorExplorer;
use App\Services\OpportunityTextSearch;
use App\Services\PublicSearchMetrics;
use App\Services\SectorInvestment;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -33,21 +24,12 @@ class InvestmentDiscoveryController extends Controller
if ($request->routeIs('investment.education')) { if ($request->routeIs('investment.education')) {
$filters['education'] = true; $filters['education'] = true;
} }
$results = $catalogue->query($filters)->paginate(12)->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record));
$relatedQuery = $results->total() === 0 ? app(OpportunityTextSearch::class)->relatedQuery($filters['q'] ?? '') : null;
$relatedFilters = [...Arr::except($filters, ['page', 'q']), 'q' => $relatedQuery];
$related = $relatedQuery === null ? null : [
'query' => $filters['q'], 'subject' => $relatedQuery,
'href' => ($request->routeIs('investment.education') ? '/educacao' : '/oportunidades').'?'.http_build_query($relatedFilters),
'items' => $catalogue->query($relatedFilters)->limit(6)->get()->map(fn ($record) => $catalogue->presentLocation($record)),
];
app(PublicSearchMetrics::class)->candidate($request, $request->routeIs('investment.education') ? 'education_page' : 'opportunities_page', $results->total(), $request->routeIs('investment.education') ? Arr::except($filters, ['education']) : $filters);
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters), return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(OpportunityCorridorExplorer::class)->groups($filters) : [], 'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters) : [],
'territorySelection' => app(InvestmentGeography::class)->selection($filters), 'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'guides' => app(SectorInvestment::class)->guides(Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()), 'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $results, 'relatedSearch' => $related, 'results' => $catalogue->query($filters)->paginate(12)->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [], 'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]); 'territorial' => $this->territorial($filters)]);
} }
@@ -92,11 +74,7 @@ class InvestmentDiscoveryController extends Controller
public function search(Request $request, InvestmentCatalogue $catalogue): JsonResponse public function search(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{ {
$filters = $catalogue->filters($request); return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)));
$results = $catalogue->query($filters)->paginate($request->integer('per_page', 12))->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record));
app(PublicSearchMetrics::class)->candidate($request, 'opportunities_api', $results->total(), $filters);
return response()->json($results);
} }
public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse
@@ -111,7 +89,7 @@ class InvestmentDiscoveryController extends Controller
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]); $data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null; $dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
if ($dossierOrigin) { if ($dossierOrigin) {
$linked = in_array($record->id, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true); $linked = in_array($record->id, app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true);
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) { foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) { if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) {
$linked = true; $linked = true;
@@ -129,8 +107,8 @@ class InvestmentDiscoveryController extends Controller
$returnFilters = $request->query(); $returnFilters = $request->query();
unset($returnFilters['zone_id'], $returnFilters['from_dossier']); unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
return Inertia::render('investment/detail', ['complements' => app(InvestmentComplements::class)->options('opportunity:'.$record->id), 'record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin, return Inertia::render('investment/detail', ['complements' => app(\App\Services\InvestmentComplements::class)->options('opportunity:'.$record->id), 'record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(SectorInvestment::class)->guides($record->sector), 'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null, 'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]); 'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
} }
@@ -157,7 +135,6 @@ class InvestmentDiscoveryController extends Controller
$filters = $catalogue->filters($request); $filters = $catalogue->filters($request);
$context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]); $context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]);
$opportunityOnlyFilters = array_filter(Arr::only($filters, ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []); $opportunityOnlyFilters = array_filter(Arr::only($filters, ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []);
$quantitative = ['state' => 'not_included', 'records' => [], 'truncated' => false];
if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) { if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
$zoneFilters = $filters; $zoneFilters = $filters;
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) { if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
@@ -169,12 +146,10 @@ class InvestmentDiscoveryController extends Controller
$zones->where('id', $filters[$key]); $zones->where('id', $filters[$key]);
} }
} }
$zoneRows = $zones->orderBy('name')->orderBy('id')->limit(31)->get(['id', 'name', 'category', 'version_no', ...(app(InvestmentZoneOperationalDetails::class)->installed() ? ['operational_details'] : [])]); $context = $context->concat($zones->orderBy('name')->limit(30)->get(['id', 'name', 'category'])->map(fn ($z) => ['id' => 'zone:'.$z->id, 'title' => $z->name, 'kind' => ['zee' => 'ZEE', 'zfi' => 'ZFI', 'industrial_park' => 'Parque industrial'][$z->category] ?? $z->category, 'url' => '/zonas/'.$z->id]));
$quantitative = app(InvestmentMapIndicators::class)->forZones($zoneRows->take(30)->values(), $zoneRows->count() > 30);
$context = $context->concat($zoneRows->take(30)->map(fn ($z) => ['id' => 'zone:'.$z->id, 'title' => $z->name, 'kind' => ['zee' => 'ZEE', 'zfi' => 'ZFI', 'industrial_park' => 'Parque industrial'][$z->category] ?? $z->category, 'url' => '/zonas/'.$z->id]));
} }
return response()->json([...$catalogue->map($filters), 'territorial_context' => $context->values(), 'quantitative_context' => $quantitative, 'context_limit_per_collection' => 30]); return response()->json([...$catalogue->map($filters), 'territorial_context' => $context->values(), 'context_limit_per_collection' => 30]);
} }
public function compare(Request $request, InvestmentCatalogue $catalogue): JsonResponse public function compare(Request $request, InvestmentCatalogue $catalogue): JsonResponse
@@ -204,16 +179,15 @@ class InvestmentDiscoveryController extends Controller
{ {
$records = $catalogue->query($catalogue->filters($request))->limit(1000)->get(); $records = $catalogue->query($catalogue->filters($request))->limit(1000)->get();
return response()->streamDownload(function () use ($records, $catalogue) { return response()->streamDownload(function () use ($records) {
$stream = fopen('php://output', 'w'); $stream = fopen('php://output', 'w');
if ($stream === false) { if ($stream === false) {
return; return;
} }
fwrite($stream, "\xEF\xBB\xBF"); fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, ['Referência', 'Título', 'Sector', 'Província', 'Montante', 'Moeda', 'Maturidade', 'URL', 'Localização e âmbito'], ';', '"', ''); fputcsv($stream, ['Referência', 'Título', 'Sector', 'Província', 'Montante', 'Moeda', 'Maturidade', 'URL'], ';', '"', '');
foreach ($records as $r) { foreach ($records as $r) {
$location = $catalogue->locationPresentation($r); $fields = [$r->reference, $r->title, $r->sector?->name, $r->province, $r->estimated_investment_amount, $r->estimated_investment_currency, $r->maturity_label, url('/oportunidades/'.$r->slug)];
$fields = [$r->reference, $r->title, $r->sector?->name, implode(' / ', $location['provinces']), $r->estimated_investment_amount, $r->estimated_investment_currency, $r->maturity_label, url('/oportunidades/'.$r->slug), $location['summary']];
fputcsv($stream, array_map(fn ($v) => preg_match('/^[\s]*[=+@-]/u', (string) $v) ? "'".$v : $v, $fields), ';', '"', ''); fputcsv($stream, array_map(fn ($v) => preg_match('/^[\s]*[=+@-]/u', (string) $v) ? "'".$v : $v, $fields), ';', '"', '');
} }
fclose($stream); fclose($stream);
@@ -1,31 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentGuides;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class InvestmentGuideController extends Controller
{
public function index(Request $request, string $slug, InvestmentGuides $guides): Response|JsonResponse
{
$data = $guides->publicView($slug, $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/guide', $data);
}
public function show(Request $request, string $id, string $slug, InvestmentGuides $guides): Response|JsonResponse
{
$data = $guides->view($request->user(), $id, $slug, $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/guide', $data);
}
public function save(Request $request, string $id, string $slug, InvestmentGuides $guides): JsonResponse
{
return response()->json($guides->save($request->user(), $id, $slug, $request->all()));
}
}
@@ -6,15 +6,10 @@ use App\Models\InvestmentDossier;
use App\Models\InvestmentSubmission; use App\Models\InvestmentSubmission;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Project; use App\Models\Project;
use App\Services\ClusterCompetitiveness;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentComplements;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations; use App\Services\InvestmentRelations;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use App\Services\PortalEditorialWorkflow;
use App\Services\PublicSearchMetrics;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -87,12 +82,7 @@ class InvestmentPortfolioController extends Controller
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds); app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds);
} }
$results = $query->orderBy('title')->paginate(30)->withQueryString(); return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
if (! $management) {
app(PublicSearchMetrics::class)->candidate($request, 'portfolio', $results->total(), $filters);
}
return Inertia::render('portfolio/index', ['records' => $results,
'management' => $management, 'filters' => $filters, 'sectors' => $sectorOptions, 'territorySelection' => $territorySelection]); 'management' => $management, 'filters' => $filters, 'sectors' => $sectorOptions, 'territorySelection' => $territorySelection]);
} }
@@ -100,10 +90,6 @@ class InvestmentPortfolioController extends Controller
{ {
$management = $request->routeIs('management.portfolio*'); $management = $request->routeIs('management.portfolio*');
$record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id); $record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id);
$editorial = $management && app(PortalEditorialWorkflow::class)->portfolioEnabled();
if ($editorial) {
$record = app(PortalEditorialWorkflow::class)->workingPortfolio($record);
}
$relatedIds = []; $relatedIds = [];
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) { foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) { foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
@@ -126,7 +112,7 @@ class InvestmentPortfolioController extends Controller
$opportunities = Opportunity::published()->where(function ($query) use ($record) { $opportunities = Opportunity::published()->where(function ($query) use ($record) {
$query->whereRaw('false'); $query->whereRaw('false');
$query->orWhereIn('id', app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record)); $query->orWhereIn('id', app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record));
foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) { foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) {
if ($record->$foreign) { if ($record->$foreign) {
$query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id')); $query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id'));
@@ -152,9 +138,8 @@ class InvestmentPortfolioController extends Controller
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at']) ->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect(); : collect();
return Inertia::render('portfolio/show', ['editorialEnabled' => $editorial, 'complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones, return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions, 'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
'geography' => app(InvestmentRelations::class)->dossier($record, $management), 'geography' => app(InvestmentRelations::class)->dossier($record, $management),
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]); 'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
} }
@@ -163,7 +148,6 @@ class InvestmentPortfolioController extends Controller
{ {
self::visible()->findOrFail($id); self::visible()->findOrFail($id);
$data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]); $data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]);
return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1'])); return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1']));
} }
@@ -172,11 +156,6 @@ class InvestmentPortfolioController extends Controller
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'], $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
'content' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'], 'content' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'],
'content.*' => ['nullable', 'string', 'max:15000']]); 'content.*' => ['nullable', 'string', 'max:15000']]);
if (app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id)) {
app(PortalEditorialWorkflow::class)->savePortfolio($request->user(), $dossier, (int) $data['version_no'], $data);
return back()->with('success', 'Edição privada guardada. A publicação anterior permanece disponível; continue no circuito editorial.');
}
DB::transaction(function () use ($request, $dossier, $data) { DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail(); $dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de guardar.'); abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de guardar.');
@@ -189,7 +168,6 @@ class InvestmentPortfolioController extends Controller
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
{ {
abort_if(app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id), 409, 'Use a aprovação e publicação separadas da edição no circuito editorial.');
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]);
DB::transaction(function () use ($request, $dossier, $data) { DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail(); $dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
@@ -1,50 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentPreparationWorkspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class InvestmentPreparationController extends Controller
{
public function show(Request $request, string $id, InvestmentPreparationWorkspace $workspace): JsonResponse|Response
{
$request->validate(['opportunity' => 'nullable|string|max:150', 'history_scope' => 'nullable|in:dossier', 'history_before' => 'nullable|integer|min:1']);
$data = $workspace->view($request->user(), $id, $request->query('opportunity'), $request->filled('history_before') ? $request->integer('history_before') : null, $request->query('history_scope') === 'dossier');
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/preparation', ['workspace' => $data]);
}
public function change(Request $request, string $id, InvestmentPreparationWorkspace $workspace): JsonResponse
{
return response()->json($workspace->change($request->user(), $id, $request->all()));
}
public function preview(Request $request, string $id, InvestmentPreparationWorkspace $workspace): \Illuminate\Http\Response
{
$request->validate(['opportunity' => 'nullable|string|max:150', 'history_scope' => 'nullable|in:dossier']);
$data = $workspace->view($request->user(), $id, $request->query('opportunity'), null, $request->query('history_scope') === 'dossier');
return response()->view('investments.preparation-index', ['workspace' => $data, 'manifest' => null])->header('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'")->header('X-Content-Type-Options', 'nosniff');
}
public function download(Request $request, string $id, string $exportId, InvestmentPreparationWorkspace $workspace): \Illuminate\Http\Response
{
$request->validate(['opportunity' => 'nullable|string|max:150', 'history_scope' => 'nullable|in:dossier']);
$bundle = $workspace->bundle($request->user(), $id, $request->query('opportunity'), $exportId, $request->query('history_scope') === 'dossier');
return response($bundle['bytes'], 200, ['Content-Type' => 'application/zip', 'Content-Disposition' => 'attachment; filename="preparacao-apiex-'.$id.'.zip"', 'X-Export-Id' => $exportId, 'X-Content-Type-Options' => 'nosniff']);
}
public function export(Request $request, string $id, InvestmentPreparationWorkspace $workspace): \Illuminate\Http\Response
{
$body = [...$request->all(), 'action' => 'apiex_export'];
$state = $workspace->change($request->user(), $id, $body);
$bundle = $workspace->bundle($request->user(), $id, $state['opportunity'], $body['operation_key']);
return response($bundle['bytes'], 200, ['Content-Type' => 'application/zip', 'Content-Disposition' => 'attachment; filename="preparacao-apiex-'.$id.'.zip"', 'X-Export-Id' => $bundle['export_id'], 'X-Content-Type-Options' => 'nosniff']);
}
}
@@ -8,11 +8,8 @@ use App\Models\EconomicCluster;
use App\Models\InvestmentGap; use App\Models\InvestmentGap;
use App\Models\Project; use App\Models\Project;
use App\Models\ValueChain; use App\Models\ValueChain;
use App\Services\ClusterCompetitiveness;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -33,35 +30,19 @@ class InvestmentStructureController extends Controller
}); });
} }
$records = $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString(); return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters,
if ($structure === 'clusters' && app(ClusterCompetitiveness::class)->editorialEnabled()) { 'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
$records->through(function ($record) {
$analysis = app(ClusterCompetitiveness::class)->current($record->id);
$record->setAttribute('editorial_state', $analysis['workflow']['state'] ?? ($analysis['approved'] ? 'legacy_published' : 'not_started'));
return $record;
});
}
return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters, 'records' => $records]);
} }
public function edit(Request $request, string $structure, ?string $id = null): Response public function edit(Request $request, string $structure, ?string $id = null): Response
{ {
$model = $this->model($structure); $model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : null; $record = $id ? $model::findOrFail($id) : null;
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id) : null;
if ($record && $editorialDossier) {
$record = app(PortalEditorialWorkflow::class)->workingStructure($record, $editorialDossier);
} elseif ($record && app(PortalEditorialSubjects::class)->available()) {
$record = clone $record;
$record->forceFill(get_object_vars(app(PortalEditorialSubjects::class)->working($structure, (object) $record->getAttributes())));
}
return Inertia::render('structures/editor', ['editorialDossier' => $editorialDossier?->id, 'editorialSubject' => ! $editorialDossier && $record && app(PortalEditorialSubjects::class)->available() ? '/management/editorial/subjects/'.$structure.'/'.$id : null, 'structuralEditorial' => app(PortalEditorialSubjects::class)->available(), 'structure' => $structure, 'record' => $record, return Inertia::render('structures/editor', ['structure' => $structure, 'record' => $record,
'projects' => $structure === 'clusters' && $id ? Project::orderBy('name')->get(['id', 'name', 'code']) : [], 'projects' => $structure === 'clusters' && $id ? Project::orderBy('name')->get(['id', 'name', 'code']) : [],
'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->get()->map(fn ($row) => app(PortalEditorialSubjects::class)->available() ? app(PortalEditorialSubjects::class)->working('value-chains', (object) $row->getAttributes()) : $row) : [], 'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->get() : [],
'gaps' => $structure === 'clusters' && $id ? InvestmentGap::where('cluster_id', $id)->orderBy('created_at')->get()->map(fn ($row) => app(PortalEditorialSubjects::class)->available() ? app(PortalEditorialSubjects::class)->working('investment-gaps', (object) $row->getAttributes()) : $row) : [], 'gaps' => $structure === 'clusters' && $id ? InvestmentGap::where('cluster_id', $id)->orderBy('created_at')->get() : [],
'chainLinks' => $structure === 'clusters' && $id ? DB::table('value_chain_links') 'chainLinks' => $structure === 'clusters' && $id ? DB::table('value_chain_links')
->join('value_chains as origins', 'origins.id', '=', 'value_chain_links.from_value_chain_id') ->join('value_chains as origins', 'origins.id', '=', 'value_chain_links.from_value_chain_id')
->join('value_chains as destinations', 'destinations.id', '=', 'value_chain_links.to_value_chain_id') ->join('value_chains as destinations', 'destinations.id', '=', 'value_chain_links.to_value_chain_id')
@@ -69,12 +50,11 @@ class InvestmentStructureController extends Controller
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at') ->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
->orderBy('origins.code')->orderBy('destinations.code') ->orderBy('origins.code')->orderBy('destinations.code')
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [], ->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [],
'competitiveness' => $structure === 'clusters' && $id ? app(ClusterCompetitiveness::class)->management($id) : null,
'viewerId' => $request->user()->id, 'viewerId' => $request->user()->id,
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects') 'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id') ->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at') ->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at')
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name'])->map(fn ($row) => app(PortalEditorialSubjects::class)->working('cluster-project', $row)) : [], ->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name']) : [],
]); ]);
} }
@@ -85,16 +65,11 @@ class InvestmentStructureController extends Controller
$isCorridor = $structure === 'corridors'; $isCorridor = $structure === 'corridors';
$model = $this->model($structure); $model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : new $model; $record = $id ? $model::findOrFail($id) : new $model;
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id, true) : null;
$subjectWrites = ! $editorialDossier && ($id ? app(PortalEditorialSubjects::class)->writes($structure, $id) : app(PortalEditorialSubjects::class)->available());
$rules = [ $rules = [
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())], 'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
'name' => ['required', 'string', 'max:200'], 'name' => ['required', 'string', 'max:200'],
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))], 'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))],
]; ];
if ($editorialDossier || ($subjectWrites && $id)) {
$rules['editorial_version'] = ['required', 'integer', 'min:1'];
}
if ($isProject || $isCorridor) { if ($isProject || $isCorridor) {
$rules['description'] = ['nullable', 'string', 'max:10000']; $rules['description'] = ['nullable', 'string', 'max:10000'];
} }
@@ -119,22 +94,6 @@ class InvestmentStructureController extends Controller
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id; $data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1; $data['territory_version'] = $path === [] ? 0 : 1;
} }
if ($editorialDossier) {
app(PortalEditorialWorkflow::class)->saveStructure($request->user(), $record, $editorialDossier, (int) $data['editorial_version'], $data);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Dados-base guardados numa edição privada da carteira. A publicação anterior está preservada.');
}
if ($subjectWrites) {
DB::transaction(function () use ($request, $record, $structure, $id, $data): void {
$fields = array_diff_key($data, ['editorial_version' => true]);
if (! $id) {
PortalAudit::save($record, $fields, $request->user()->id);
}
app(PortalEditorialWorkflow::class)->saveSubject($request->user(), $structure, (string) $record->getKey(), $id ? (int) $data['editorial_version'] : 0, $fields, ! $id);
});
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Proposta privada guardada. Reveja a edição antes de publicar as alterações.');
}
PortalAudit::save($record, $data, $request->user()->id); PortalAudit::save($record, $data, $request->user()->id);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.'); return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
@@ -153,7 +112,6 @@ class InvestmentStructureController extends Controller
public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse
{ {
$editorial = app(PortalEditorialSubjects::class)->available();
$data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')], $data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'version_no' => ['required', 'integer', 'min:0'], 'version_no' => ['required', 'integer', 'min:0'],
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])], 'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])],
@@ -161,21 +119,10 @@ class InvestmentStructureController extends Controller
'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'], 'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
'valid_from' => ['required', 'date_format:Y-m-d'], 'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'], 'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]);
...($editorial ? ['editorial_version' => ['required', 'integer', 'min:0']] : [])]); DB::transaction(function () use ($cluster, $data, $request) {
DB::transaction(function () use ($cluster, $data, $request, $editorial) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first(); $link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first();
if ($editorial || ($link && app(PortalEditorialSubjects::class)->writes('cluster-project', $link->id))) {
abort_unless((int) $data['version_no'] === ($link ? (int) $link->version_no : 0), 409, 'A associação foi alterada. Confira a versão actual.');
$fields = [...array_diff_key($data, ['version_no' => true, 'editorial_version' => true]), 'cluster_id' => $cluster->id];
if (! $link) {
$fields += ['validation_status' => 'pending', 'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => 1];
}
app(PortalEditorialSubjects::class)->stageModel($request->user(), 'cluster-project', $link ?? new ClusterProject, $fields, (int) $data['editorial_version']);
return;
}
if ($link) { if ($link) {
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.'); abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
$changed = false; $changed = false;
@@ -206,17 +153,10 @@ class InvestmentStructureController extends Controller
public function saveChain(Request $request, EconomicCluster $cluster, ?ValueChain $chain = null): RedirectResponse public function saveChain(Request $request, EconomicCluster $cluster, ?ValueChain $chain = null): RedirectResponse
{ {
abort_if($chain && $chain->cluster_id !== $cluster->id, 404); abort_if($chain && $chain->cluster_id !== $cluster->id, 404);
$editorial = $chain ? app(PortalEditorialSubjects::class)->writes('value-chains', $chain->id) : app(PortalEditorialSubjects::class)->available();
$data = $request->validate(['code' => ['required', 'string', 'max:100', $data = $request->validate(['code' => ['required', 'string', 'max:100',
Rule::unique('value_chains', 'code')->where('cluster_id', $cluster->id)->whereNull('deleted_at')->ignore($chain?->id)], Rule::unique('value_chains', 'code')->where('cluster_id', $cluster->id)->whereNull('deleted_at')->ignore($chain?->id)],
'name' => ['required', 'string', 'max:200'], 'name' => ['required', 'string', 'max:200'],
'stage' => ['required', Rule::in(['input', 'production', 'processing', 'logistics', 'market'])], 'stage' => ['required', Rule::in(['input', 'production', 'processing', 'logistics', 'market'])]]);
...($editorial && $chain ? ['editorial_version' => ['required', 'integer', 'min:1']] : [])]);
if ($editorial) {
$edition = app(PortalEditorialSubjects::class)->stageModel($request->user(), 'value-chains', $chain ?? new ValueChain, [...array_diff_key($data, ['editorial_version' => true]), 'cluster_id' => $cluster->id], $chain ? (int) $data['editorial_version'] : 0);
return redirect('/management/editorial/subjects/value-chains/'.$edition->subject_key)->with('success', 'Etapa guardada numa proposta privada.');
}
PortalAudit::save($chain ?? new ValueChain, [...$data, 'cluster_id' => $cluster->id], $request->user()->id); PortalAudit::save($chain ?? new ValueChain, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Etapa da cadeia de valor guardada.'); return back()->with('success', 'Etapa da cadeia de valor guardada.');
@@ -2,8 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -32,8 +30,7 @@ class InvestmentStructureRelationController extends Controller
...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []), ...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []),
]); ]);
$proposal = null; DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request): void {
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request, $kind, &$proposal): void {
DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail(); DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail();
$existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first(); $existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first();
abort_if($existing !== null, 409, 'Associacao ja registada.'); abort_if($existing !== null, 409, 'Associacao ja registada.');
@@ -43,15 +40,8 @@ class InvestmentStructureRelationController extends Controller
$id = (string) Str::uuid(); $id = (string) Str::uuid();
DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']); DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']);
$this->audit($request, $table, $id, 'submitted', 1); $this->audit($request, $table, $id, 'submitted', 1);
if (app(PortalEditorialSubjects::class)->available()) {
$proposal = app(PortalEditorialWorkflow::class)->saveSubject($request->user(), $kind, $id, 0, $data, true);
}
}); });
if ($proposal) {
return redirect('/management/editorial/subjects/'.$kind.'/'.$proposal->subject_key)->with('success', 'Associação guardada numa proposta privada.');
}
return back()->with('success', 'Associacao submetida para revisao independente.'); return back()->with('success', 'Associacao submetida para revisao independente.');
} }
@@ -60,12 +50,6 @@ class InvestmentStructureRelationController extends Controller
abort_unless(in_array($action, ['validate', 'revoke'], true), 404); abort_unless(in_array($action, ['validate', 'revoke'], true), 404);
[$table] = $this->definition($kind); [$table] = $this->definition($kind);
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
if (app(PortalEditorialSubjects::class)->writes($kind, $id)) {
app(PortalEditorialSubjects::class)->authorize($request->user(), $kind, $id, true);
abort_if($request->expectsJson(), 409, 'Continue em /management/editorial/subjects/'.$kind.'/'.$id.' para rever a edição e publicar a decisão separadamente.');
return redirect('/management/editorial/subjects/'.$kind.'/'.$id)->with('success', 'Prepare ou confira a edição antes de decidir. Para retirar, marque a proposta de retirada.');
}
DB::transaction(function () use ($request, $table, $id, $action, $data): void { DB::transaction(function () use ($request, $table, $id, $action, $data): void {
$relation = DB::table($table)->where('id', $id)->lockForUpdate()->first(); $relation = DB::table($table)->where('id', $id)->lockForUpdate()->first();
@@ -10,7 +10,6 @@ use App\Models\Opportunity;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\InterestSubmission; use App\Services\InterestSubmission;
use App\Services\InvestmentCatalogue; use App\Services\InvestmentCatalogue;
use App\Services\InvestmentConsumerScope;
use App\Services\InvestmentWorkspace; use App\Services\InvestmentWorkspace;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
use App\Services\InvestorContextConsumers; use App\Services\InvestorContextConsumers;
@@ -152,7 +151,7 @@ class InvestmentSubmissionController extends Controller
public function bindContext(Request $request, string $id): JsonResponse public function bindContext(Request $request, string $id): JsonResponse
{ {
abort_unless(app(VirtualDesk::class)->installed(), 422, 'O vínculo ao percurso do investimento não está disponível neste destino. A manifestação e a sua origem permanecem guardadas.'); abort_unless(app(VirtualDesk::class)->installed(), 422, 'O vínculo ao percurso do investimento não está disponível neste destino. A manifestação e a sua origem permanecem guardadas.');
abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']) !== [], 422, 'Parâmetros de associação inválidos.'); abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']), 422, 'Parâmetros de associação inválidos.');
$data = $request->validate(['case_id' => 'required|uuid', 'case_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1']); $data = $request->validate(['case_id' => 'required|uuid', 'case_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1']);
$result = DB::transaction(function () use ($request, $id, $data) { $result = DB::transaction(function () use ($request, $id, $data) {
// Serialize all links for this submission before locking the selected case. // Serialize all links for this submission before locking the selected case.
@@ -251,12 +250,8 @@ class InvestmentSubmissionController extends Controller
} }
$bound = DB::table('desk_cases')->where('submission_id', $request->route('id'))->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first(); $bound = DB::table('desk_cases')->where('submission_id', $request->route('id'))->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first();
if ($bound) { if ($bound) $data['payload'] = app(\App\Services\InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting);
$data['payload'] = app(InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting); else unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
} else {
unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
}
return $data; return $data;
} }
@@ -276,7 +271,7 @@ class InvestmentSubmissionController extends Controller
$version = DB::transaction(function () use ($request, $id, $data) { $version = DB::transaction(function () use ($request, $id, $data) {
$row = $this->owned($request, $id, true, true); $row = $this->owned($request, $id, true, true);
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.'); abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.');
$row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id' => (int) substr($data['payload']['context_opportunity'], 12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]); $row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id'=>(int)substr($data['payload']['context_opportunity'],12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
InvestmentSpecificNeed::where('submission_id', $id)->delete(); InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) { foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]); InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -321,7 +316,7 @@ class InvestmentSubmissionController extends Controller
} }
abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.'); abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.');
$scopeKey = $data['payload']['context_opportunity'] ?? null; $scopeKey = $data['payload']['context_opportunity'] ?? null;
$opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey, 12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null); $opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey,12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null);
if ($row->dossier_id) { if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id); InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
} }
@@ -349,7 +344,7 @@ class InvestmentSubmissionController extends Controller
'incorporation_country_code' => $p['incorporation_country_code'] ?? null, 'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []), ...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []),
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]])); ...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]]));
$row->update(['opportunity_id' => $opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']); $row->update(['opportunity_id'=>$opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete(); InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) { foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]); InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -2,16 +2,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\InvestmentZone;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Project;
use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZones; use App\Services\InvestmentZones;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\TerritoryGeometry; use App\Services\TerritoryGeometry;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -24,7 +16,7 @@ class InvestmentTerritoryController extends Controller
{ {
public function geometries(Request $request): JsonResponse public function geometries(Request $request): JsonResponse
{ {
$data = $request->validate(['territory_id' => ['required', 'uuid'], 'include_children' => ['sometimes', 'boolean']]); $data = $request->validate(['territory_id' => ['required', 'uuid']]);
$profile = $request->is('minhas-oportunidades/*'); $profile = $request->is('minhas-oportunidades/*');
$management = $request->is('management/*') || $profile; $management = $request->is('management/*') || $profile;
$actor = $request->user(); $actor = $request->user();
@@ -39,7 +31,7 @@ class InvestmentTerritoryController extends Controller
} }
return response()->json([ return response()->json([
...app(TerritoryGeometry::class)->context($data['territory_id'], $internal, $request->boolean('include_children')), ...app(TerritoryGeometry::class)->context($data['territory_id'], $internal),
'geometry_scope' => $internal ? 'authorized_internal' : 'public', 'geometry_scope' => $internal ? 'authorized_internal' : 'public',
])->header('Cache-Control', 'private, no-store'); ])->header('Cache-Control', 'private, no-store');
} }
@@ -90,18 +82,6 @@ class InvestmentTerritoryController extends Controller
$table = self::TABLES[$kind] ?? abort(404); $table = self::TABLES[$kind] ?? abort(404);
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first(); $record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($record !== null, 404); abort_unless($record !== null, 404);
if ($dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id)) {
$model = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind] ?? abort(404);
$working = app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier);
$record->primary_territory_id = $working->getAttribute('primary_territory_id');
$record->territory_version = $working->getAttribute('territory_version');
} elseif (in_array($kind, ['projects', 'clusters', 'corridors'], true) && app(PortalEditorialSubjects::class)->available()) {
$record = app(PortalEditorialSubjects::class)->working($kind, $record);
} elseif ($kind === 'zones' && app(InvestmentZoneOperationalProfile::class)->installed()) {
$working = app(InvestmentZoneOperationalProfile::class)->management(InvestmentZone::findOrFail($id));
$record->primary_territory_id = $working['primary_territory_id'];
$record->territory_version = $working['territory_version'];
}
$path = []; $path = [];
$current = $record->primary_territory_id; $current = $record->primary_territory_id;
for ($i = 0; $current && $i < 5; $i++) { for ($i = 0; $current && $i < 5; $i++) {
@@ -113,7 +93,7 @@ class InvestmentTerritoryController extends Controller
$current = $location->parent_id; $current = $location->parent_id;
} }
return response()->json(['version' => $record->territory_version, 'path' => $path])->header('Cache-Control', 'private, no-store'); return response()->json(['version' => $record->territory_version, 'path' => $path]);
} }
public function save(Request $request, string $kind, string $id): JsonResponse public function save(Request $request, string $kind, string $id): JsonResponse
@@ -123,29 +103,11 @@ class InvestmentTerritoryController extends Controller
abort_unless($request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403); abort_unless($request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
} }
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]); $data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]);
$dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id, true); $version = DB::transaction(function () use ($request, $table, $id, $data) {
$structural = ! $dossier && in_array($kind, ['projects', 'clusters', 'corridors'], true) && app(PortalEditorialSubjects::class)->writes($kind, $id);
$version = DB::transaction(function () use ($request, $table, $id, $data, $dossier, $kind, $structural) {
if ($dossier) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
}
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)'); DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
if ($structural) {
app(PortalEditorialSubjects::class)->lock($kind, $id, ['primary_territory_id' => $data['path'] === [] ? null : $data['path'][array_key_last($data['path'])]]);
}
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first(); $record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404); abort_unless($record !== null, 404);
$effectiveVersion = (int) $record->territory_version; abort_unless((int) $record->territory_version === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
if ($dossier) {
$model = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind] ?? abort(404);
$effectiveVersion = (int) app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier)->getAttribute('territory_version');
} elseif ($structural) {
$effectiveVersion = (int) app(PortalEditorialSubjects::class)->working($kind, $record)->territory_version;
} elseif ($kind === 'zones' && app(InvestmentZoneOperationalProfile::class)->installed()) {
$working = app(InvestmentZoneOperationalProfile::class)->management(InvestmentZone::findOrFail($id));
$effectiveVersion = (int) $working['territory_version'];
}
abort_unless($effectiveVersion === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
$parent = null; $parent = null;
$names = []; $names = [];
foreach (array_values($data['path']) as $index => $locationId) { foreach (array_values($data['path']) as $index => $locationId) {
@@ -157,26 +119,13 @@ class InvestmentTerritoryController extends Controller
$names[] = $location->name; $names[] = $location->name;
} }
$version = $data['version'] + 1; $version = $data['version'] + 1;
if ($dossier) {
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveStructure($request->user(), $model::findOrFail($id), $dossier, (int) $workflow->workingPortfolio($dossier)->getAttribute('version_no'), ['primary_territory_id' => $parent, 'territory_version' => $version]);
return $version;
}
if ($structural) {
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveSubject($request->user(), $kind, $id, $workflow->subjectVersion($kind, $id), ['primary_territory_id' => $parent, 'territory_version' => $version]);
return $version;
}
$changes = ['primary_territory_id' => $parent, 'territory_version' => $version, 'updated_at' => now()]; $changes = ['primary_territory_id' => $parent, 'territory_version' => $version, 'updated_at' => now()];
if (in_array($table, ['opportunities', 'investment_zones'], true)) { if (in_array($table, ['opportunities', 'investment_zones'], true)) {
$changes['province'] = $names[0] ?? null; $changes['province'] = $names[0] ?? null;
$changes['district'] = $names[1] ?? null; $changes['district'] = $names[1] ?? null;
} }
if ($table === 'investment_zones') { if ($table === 'investment_zones') {
$workingVersion = app(InvestmentZoneOperationalProfile::class)->workingVersion(InvestmentZone::findOrFail($id)); app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $record->version_no], $request->user()->id);
app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $workingVersion], $request->user()->id);
} else { } else {
DB::table($table)->where('id', $id)->update($changes); DB::table($table)->where('id', $id)->update($changes);
} }
@@ -187,8 +136,6 @@ class InvestmentTerritoryController extends Controller
return $version; return $version;
}); });
$zoneDraft = $kind === 'zones' && (app(InvestmentZoneOperationalProfile::class)->editorialEnabled() || DB::table('investment_zones')->where('id', $id)->value('category') === 'industrial_park'); return response()->json(['version' => $version]);
return response()->json(['version' => $version, 'pending_publication' => $dossier !== null || $structural || $zoneDraft]);
} }
} }
@@ -3,18 +3,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\InvestmentSubmission; use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User; use App\Models\User;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\FundingProgrammes;
use App\Services\InvestmentGeography;
use App\Services\InvestmentOpportunityAlerts;
use App\Services\InvestmentProfileCommands;
use App\Services\InvestmentWorkspace; use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement;
use App\Services\OrganizationCountry; use App\Services\OrganizationCountry;
use App\Services\Procurement;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -26,9 +18,9 @@ use Inertia\Response;
class InvestmentWorkspaceController extends Controller class InvestmentWorkspaceController extends Controller
{ {
public function profile(Request $request, InvestmentWorkspace $workspace): \Symfony\Component\HttpFoundation\Response public function profile(Request $request, InvestmentWorkspace $workspace): Response|JsonResponse
{ {
$data = ['actorId' => $request->user()->id, 'organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations') $data = ['organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) { ->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) {
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR); $company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$company->countrySource = $company->payload['organization_country_snapshot'] ?? null; $company->countrySource = $company->payload['organization_country_snapshot'] ?? null;
@@ -36,17 +28,12 @@ class InvestmentWorkspaceController extends Controller
return $company; return $company;
})]; })];
Inertia::encryptHistory(); return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data);
$response = $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data)->toResponse($request);
$response->headers->set('Cache-Control', 'no-store, private');
return $response;
} }
public function saveProfile(Request $request): RedirectResponse|JsonResponse public function saveProfile(Request $request): RedirectResponse|JsonResponse
{ {
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'operation_key' => ['sometimes', 'required', 'uuid'], $data = $request->validate(['version' => ['required', 'integer', 'min:0'],
'payload' => ['required', 'array:phone,country,residence_country_code,sector,experience,investment_capacity'], 'payload' => ['required', 'array:phone,country,residence_country_code,sector,experience,investment_capacity'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => ['nullable', 'string', 'max:100'], 'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => ['nullable', 'string', 'max:100'],
@@ -54,19 +41,12 @@ class InvestmentWorkspaceController extends Controller
'payload.investment_capacity' => ['nullable', 'string', 'max:1000']]); 'payload.investment_capacity' => ['nullable', 'string', 'max:1000']]);
$version = DB::transaction(function () use ($request, $data) { $version = DB::transaction(function () use ($request, $data) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail(); DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail();
$commands = app(InvestmentProfileCommands::class);
$command = ['kind' => 'account', 'target_id' => null, 'version' => (int) $data['version'], 'payload' => $data['payload']];
$replay = $commands->replay($request->user(), $data['operation_key'] ?? null, $command);
if ($replay !== null) {
return $replay['version'];
}
$row = DB::table('investment_accounts')->where('user_id', $request->user()->id)->first(); $row = DB::table('investment_accounts')->where('user_id', $request->user()->id)->first();
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Perfil alterado. Recarregue.'); abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Perfil alterado. Recarregue.');
$version = (int) $data['version'] + 1; $version = (int) $data['version'] + 1;
DB::table('investment_accounts')->updateOrInsert(['user_id' => $request->user()->id], ['version' => $version, DB::table('investment_accounts')->updateOrInsert(['user_id' => $request->user()->id], ['version' => $version,
'payload' => Crypt::encryptString(json_encode($data['payload'], JSON_THROW_ON_ERROR)), 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]); 'payload' => Crypt::encryptString(json_encode($data['payload'], JSON_THROW_ON_ERROR)), 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($request, 'profile_saved', 'investment_accounts', null, ['version' => $version]); $this->audit($request, 'profile_saved', 'investment_accounts', null, ['version' => $version]);
$commands->record($request->user(), $data['operation_key'] ?? null, $command, ['version' => $version]);
return $version; return $version;
}); });
@@ -76,7 +56,7 @@ class InvestmentWorkspaceController extends Controller
public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse
{ {
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'operation_key' => ['sometimes', 'required', 'uuid'], $data = $request->validate(['version' => ['required', 'integer', 'min:0'],
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'], 'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'], 'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'],
@@ -86,12 +66,6 @@ class InvestmentWorkspaceController extends Controller
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail(); DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail();
$row = $id ? DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->first() : null; $row = $id ? DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->first() : null;
abort_if($id && ! $row, 404); abort_if($id && ! $row, 404);
$commands = app(InvestmentProfileCommands::class);
$command = ['kind' => 'company', 'target_id' => $id, 'version' => (int) $data['version'], 'payload' => $data['payload']];
$replay = $commands->replay($request->user(), $data['operation_key'] ?? null, $command);
if ($replay !== null) {
return $replay;
}
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Declaração alterada. Recarregue.'); abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Declaração alterada. Recarregue.');
abort_if(! $id && DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->count() >= 20, 422, 'Máximo de vinte empresas declaradas.'); abort_if(! $id && DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->count() >= 20, 422, 'Máximo de vinte empresas declaradas.');
$id ??= (string) Str::uuid(); $id ??= (string) Str::uuid();
@@ -105,7 +79,6 @@ class InvestmentWorkspaceController extends Controller
DB::table('investment_company_declarations')->updateOrInsert(['id' => $id], ['user_id' => $request->user()->id, 'version' => $version, DB::table('investment_company_declarations')->updateOrInsert(['id' => $id], ['user_id' => $request->user()->id, 'version' => $version,
'payload' => $encrypted, 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]); 'payload' => $encrypted, 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($request, 'company_declared', 'investment_company_declarations', $id, ['version' => $version, 'before_payload' => $row?->payload, 'after_payload' => $encrypted]); $this->audit($request, 'company_declared', 'investment_company_declarations', $id, ['version' => $version, 'before_payload' => $row?->payload, 'after_payload' => $encrypted]);
$commands->record($request->user(), $data['operation_key'] ?? null, $command, ['id' => $id, 'version' => $version]);
return ['id' => $id, 'version' => $version]; return ['id' => $id, 'version' => $version];
}); });
@@ -122,25 +95,13 @@ class InvestmentWorkspaceController extends Controller
public function reuseOrganizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse public function reuseOrganizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse
{ {
$data = $request->validate(['version' => 'required|integer|min:1', 'organization_id' => 'required|uuid', 'organization_version' => 'required|integer|min:1', 'operation_key' => ['sometimes', 'required', 'uuid']]); $data = $request->validate(['version' => 'required|integer|min:1', 'organization_id' => 'required|uuid', 'organization_version' => 'required|integer|min:1']);
$result = DB::transaction(function () use ($request, $id, $data, $service) { $result = DB::transaction(function () use ($request, $id, $data, $service) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail();
$row = DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->lockForUpdate()->first(); $row = DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->lockForUpdate()->first();
abort_unless($row !== null, 404); abort_unless($row !== null, 404);
$snapshot = $service->readableSnapshot($request->user(), $data['organization_id']);
$commands = app(InvestmentProfileCommands::class);
$command = ['kind' => 'organization_country_reuse', 'target_id' => $row->id, 'version' => (int) $data['version'], 'payload' => ['organization_id' => $snapshot['organization_id'], 'organization_version' => (int) $data['organization_version']]];
$replay = $commands->replay($request->user(), $data['operation_key'] ?? null, $command);
if ($replay !== null) {
if (! isset($replay['country'])) {
throw new \LogicException('Stored country reuse receipt unavailable.');
}
return ['version' => $replay['version'], 'country' => $replay['country']];
}
abort_unless((int) $row->version === (int) $data['version'], 409, 'Declaração alterada. Recarregue.'); abort_unless((int) $row->version === (int) $data['version'], 409, 'Declaração alterada. Recarregue.');
$snapshot = $service->snapshot($request->user(), $data['organization_id']);
abort_unless((int) $snapshot['organization_version'] === (int) $data['organization_version'], 409, 'País mestre alterado. Recarregue.'); abort_unless((int) $snapshot['organization_version'] === (int) $data['organization_version'], 409, 'País mestre alterado. Recarregue.');
abort_unless($snapshot['country'] !== null, 422, 'País de constituição não informado no cadastro mestre.');
$payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR); $payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
$payload['incorporation_country_code'] = $snapshot['country']; $payload['incorporation_country_code'] = $snapshot['country'];
$payload['organization_country_snapshot'] = $snapshot; $payload['organization_country_snapshot'] = $snapshot;
@@ -148,49 +109,24 @@ class InvestmentWorkspaceController extends Controller
DB::table('investment_company_declarations')->where('id', $id)->update(['payload' => $encrypted, 'version' => $row->version + 1, 'updated_at' => now()]); DB::table('investment_company_declarations')->where('id', $id)->update(['payload' => $encrypted, 'version' => $row->version + 1, 'updated_at' => now()]);
$this->audit($request, 'organization_country_reused', 'investment_company_declarations', $id, ['version' => $row->version + 1, $this->audit($request, 'organization_country_reused', 'investment_company_declarations', $id, ['version' => $row->version + 1,
'before_payload' => $row->payload, 'after_payload' => $encrypted, 'organization_id' => $data['organization_id'], 'organization_version' => $snapshot['organization_version']]); 'before_payload' => $row->payload, 'after_payload' => $encrypted, 'organization_id' => $data['organization_id'], 'organization_version' => $snapshot['organization_version']]);
$commands->record($request->user(), $data['operation_key'] ?? null, $command, ['version' => $row->version + 1, 'country' => $snapshot['country']]);
return ['version' => $row->version + 1, 'country' => $snapshot['country']]; return ['version' => $row->version + 1, 'country' => $snapshot['country']];
}); });
return $request->expectsJson() ? response()->json($result)->header('Cache-Control', 'no-store, private') : back()->with('success', 'País mestre reutilizado nesta declaração; manifestações anteriores não alteradas.'); return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre reutilizado nesta declaração; manifestações anteriores não alteradas.');
} }
public function notifications(Request $request): Response|JsonResponse public function notifications(Request $request): Response|JsonResponse
{ {
$query = DB::table('investment_notifications')->where('user_id', $request->user()->id); $query = DB::table('investment_notifications')->where('user_id', $request->user()->id);
$alerts = app(InvestmentOpportunityAlerts::class); if (! $request->user()->can('manage-portal')) {
$available = $alerts->installed();
if ($available) {
$query->where(function ($sources) use ($request, $alerts) {
$sources->whereIn('opportunity_id', Opportunity::published()->select('id'))
->orWhere(function ($legacy) use ($request) {
$legacy->whereNotNull('submission_id');
if (! $request->user()->can('manage-portal')) {
$legacy->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id'));
}
});
if ($alerts->tendersAvailable()) {
$sources->orWhereIn('tender_version_id', app(Procurement::class)->readableVersions()->select('id'));
}
if ($alerts->fundingAvailable()) {
$sources->orWhereIn('funding_version_id', app(FundingProgrammes::class)->readableVersions()->select('id'));
}
});
} elseif (! $request->user()->can('manage-portal')) {
$query->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id')); $query->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id'));
} }
$data = ['alertPreferences' => $available ? $alerts->preferences($request->user()->id) : null, $data = ['unread' => (clone $query)->whereNull('read_at')->count(), 'notifications' => $query->orderByDesc('created_at')->paginate(20)->through(function ($row) {
'alertOptions' => $available ? ['frequencies' => InvestmentOpportunityAlerts::FREQUENCIES, $row->payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
'sectors' => Sector::choices()->whereNotNull('published_at')->orderBy('name')->get(['slug', 'name']),
'provinces' => app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->orderBy('name')->get(['id', 'name']),
'categories' => OpportunityManagement::CATEGORIES, 'tendersAvailable' => $alerts->tendersAvailable(), 'fundingAvailable' => $alerts->fundingAvailable(),
'tenderRegimes' => config('procurement.regimes'), 'tenderActs' => config('procurement.acts')] : null,
'unread' => (clone $query)->whereNull('read_at')->count(), 'notifications' => $query->orderByDesc('created_at')->orderByDesc('id')->paginate(20)->through(function ($row) {
$row->payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
return $row; return $row;
})]; })];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/notifications', $data); return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/notifications', $data);
} }
@@ -199,17 +135,7 @@ class InvestmentWorkspaceController extends Controller
{ {
$row = DB::table('investment_notifications')->where('id', $id)->where('user_id', $request->user()->id)->first(); $row = DB::table('investment_notifications')->where('id', $id)->where('user_id', $request->user()->id)->first();
abort_unless($row !== null, 404); abort_unless($row !== null, 404);
if ($row->opportunity_id ?? null) { $this->submission($request, $row->submission_id);
abort_unless(Opportunity::published()->whereKey($row->opportunity_id)->exists(), 404);
} elseif ($row->tender_version_id ?? null) {
abort_unless(app(InvestmentOpportunityAlerts::class)->tendersAvailable()
&& app(Procurement::class)->readableVersions()->where('id', $row->tender_version_id)->exists(), 404);
} elseif ($row->funding_version_id ?? null) {
abort_unless(app(InvestmentOpportunityAlerts::class)->fundingAvailable()
&& app(FundingProgrammes::class)->readableVersions()->where('id', $row->funding_version_id)->exists(), 404);
} else {
$this->submission($request, $row->submission_id);
}
DB::table('investment_notifications')->where('id', $id)->whereNull('read_at')->update(['read_at' => now()]); DB::table('investment_notifications')->where('id', $id)->whereNull('read_at')->update(['read_at' => now()]);
return $request->expectsJson() ? response()->json(['read' => true]) : back(); return $request->expectsJson() ? response()->json(['read' => true]) : back();
@@ -5,22 +5,16 @@ namespace App\Http\Controllers;
use App\Models\InvestmentDossier; use App\Models\InvestmentDossier;
use App\Models\InvestmentZone; use App\Models\InvestmentZone;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\EconomicReferenceCatalog;
use App\Services\InvestmentCatalogue; use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentTerritorialTraceability; use App\Services\InvestmentTerritorialTraceability;
use App\Services\InvestmentZoneAccess; use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZoneOperationalDetails; use App\Services\InvestmentZones;
use App\Services\InvestmentZoneOperationalDetailsInstallation;
use App\Services\InvestmentZoneOperationalProfile; use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZonePathContext; use App\Services\InvestmentZonePathContext;
use App\Services\InvestmentZoneProfileInstallation; use App\Services\InvestmentZoneProfileInstallation;
use App\Services\InvestmentZones;
use App\Services\PublicSearchMetrics;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -39,7 +33,7 @@ class InvestmentZoneController extends Controller
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province); $zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district); $zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
$zone->setAttribute('territory_path', $territoryPath); $zone->setAttribute('territory_path', $territoryPath);
$zone->setAttribute('operational_details_public', app(InvestmentZoneOperationalDetails::class)->publicData($zone)); $zone->setAttribute('operational_details_public',app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone));
$publicFields = ['operational_details_public', 'version_no', 'published_at', 'id', 'code', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'historical_territory', 'data_as_of', 'sources_consulted_at', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'territory_path', 'validation_status', 'updated_at']; $publicFields = ['operational_details_public', 'version_no', 'published_at', 'id', 'code', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'historical_territory', 'data_as_of', 'sources_consulted_at', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'territory_path', 'validation_status', 'updated_at'];
$locations = DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id') $locations = DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
->where('zl.zone_id', $id)->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')) ->where('zl.zone_id', $id)->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
@@ -74,7 +68,7 @@ class InvestmentZoneController extends Controller
}); });
$props = ['record' => $management ? app(InvestmentZoneOperationalProfile::class)->management($zone) : app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->only($publicFields)), 'source' => $source, 'operator' => $operator, 'management' => $management, $props = ['record' => $management ? app(InvestmentZoneOperationalProfile::class)->management($zone) : app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->only($publicFields)), 'source' => $source, 'operator' => $operator, 'management' => $management,
'profileAvailable' => $management && app(InvestmentZoneOperationalProfile::class)->installed(), 'profileAvailable' => $management && app(InvestmentZoneOperationalProfile::class)->installed(),
'zoneContext' => ! $management && $zone->category === 'industrial_park' ? app(InvestmentZonePathContext::class)->context($id, (int) $zone->version_no) : null, 'zoneContext' => !$management && $zone->category === 'industrial_park' ? app(InvestmentZonePathContext::class)->context($id,(int)$zone->version_no) : null,
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence'), 'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence'),
'related' => $related->unique('id')->values(), 'related' => $related->unique('id')->values(),
'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [], 'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
@@ -83,7 +77,7 @@ class InvestmentZoneController extends Controller
'dossiers' => InvestmentDossier::orderBy('title')->limit(200)->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id'])] : null, 'dossiers' => InvestmentDossier::orderBy('title')->limit(200)->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id'])] : null,
'opportunities' => $opportunities, 'opportunities' => $opportunities,
'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [], 'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [],
'economicActivities' => $management ? app(EconomicReferenceCatalog::class)->activities() : [], 'economicActivities' => $management ? app(\App\Services\EconomicReferenceCatalog::class)->activities() : [],
'operators' => $management ? DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->limit(200)->get(['id', 'name']) : [], 'operators' => $management ? DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->limit(200)->get(['id', 'name']) : [],
'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [], 'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
'locations' => $locations, 'locations' => $locations,
@@ -105,15 +99,10 @@ class InvestmentZoneController extends Controller
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.'); 'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
$path = app(InvestmentGeography::class)->selection($filters); $path = app(InvestmentGeography::class)->selection($filters);
$publicColumns = ['primary_territory_id', 'version_no', 'id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at']; $publicColumns = ['primary_territory_id', 'version_no', 'id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'];
if (app(InvestmentZoneOperationalDetails::class)->installed()) { if(app(\App\Services\InvestmentZoneOperationalDetails::class)->installed()){$publicColumns[]='operational_details';}
$publicColumns[] = 'operational_details';
}
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column))); $publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id') $rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString(); ->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString();
if (! $management) {
app(PublicSearchMetrics::class)->candidate($request, 'zones', $rows->total(), $filters);
}
$rows->getCollection()->transform(function (InvestmentZone $zone) use ($management) { $rows->getCollection()->transform(function (InvestmentZone $zone) use ($management) {
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management); $territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province); $zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
@@ -124,13 +113,11 @@ class InvestmentZoneController extends Controller
->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')) ->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type'])); ->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type']));
if (! $management) { if (! $management) { $zone->makeHidden('primary_territory_id'); }
$zone->makeHidden('primary_territory_id');
}
return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()), 'operational_details_public' => app(InvestmentZoneOperationalDetails::class)->publicData($zone)]; return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()),'operational_details_public'=>app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone)];
}); });
$props = ['detailsInstallation' => $management ? app(InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, 'records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path, $props = ['detailsInstallation'=>$management ? app(\App\Services\InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, 'records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path,
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence')]; 'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence')];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props); return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
@@ -206,35 +193,35 @@ class InvestmentZoneController extends Controller
'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'], 'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'],
'aliases' => ['sometimes', 'array', 'max:20'], 'aliases.*' => ['string', 'max:120'], 'aliases' => ['sometimes', 'array', 'max:20'], 'aliases.*' => ['string', 'max:120'],
'operational_details' => ['sometimes', 'array'], 'operational_details' => ['sometimes','array'],
'activities' => ['sometimes', 'array', 'max:50'], 'activities.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'], 'activities' => ['sometimes', 'array', 'max:50'], 'activities.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'],
'activities.*.id' => ['nullable', 'uuid'], 'activities.*.id' => ['nullable','uuid'],
'activities.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'activities.*.source_date' => ['nullable','date_format:Y-m-d'],
'activities.*.kind' => ['nullable', 'in:infrastructure,service,access,other'], 'activities.*.kind' => ['nullable','in:infrastructure,service,access,other'],
'activities.*.scope' => ['nullable', 'in:inside,regional,unknown'], 'activities.*.scope' => ['nullable','in:inside,regional,unknown'],
'activities.*.capacity_type' => ['nullable', 'string', 'max:120'], 'activities.*.capacity_type' => ['nullable','string','max:120'],
'activities.*.capacity_value' => ['nullable', 'numeric', 'min:0'], 'activities.*.capacity_value' => ['nullable','numeric','min:0'],
'activities.*.capacity_unit' => ['nullable', 'string', 'max:40'], 'activities.*.capacity_unit' => ['nullable','string','max:40'],
'activities.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'], 'activities.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
'activities.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'], 'activities.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
'activities.*.support_document_ids' => ['sometimes', 'array', 'max:10'], 'activities.*.support_document_ids' => ['sometimes','array','max:10'],
'activities.*.support_document_ids.*' => ['uuid'], 'activities.*.support_document_ids.*' => ['uuid'],
'activities.*.private_note' => ['nullable', 'string', 'max:2000'], 'activities.*.private_note' => ['nullable','string','max:2000'],
'activities.*.name' => ['required', 'string', 'max:300'], 'activities.*.status' => ['required', 'in:proposed,authorized,conditional,excluded,potential'], 'activities.*.name' => ['required', 'string', 'max:300'], 'activities.*.status' => ['required', 'in:proposed,authorized,conditional,excluded,potential'],
'activities.*.source_reference' => ['nullable', 'string', 'max:2000'], 'activities.*.source_reference' => ['nullable', 'string', 'max:2000'],
'resources' => ['sometimes', 'array', 'max:50'], 'resources.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'], 'resources' => ['sometimes', 'array', 'max:50'], 'resources.*' => ['array:id,name,status,source_reference,source_date,kind,scope,capacity_type,capacity_value,capacity_unit,focus,economic_activity_id,support_document_ids,private_note'],
'resources.*.id' => ['nullable', 'uuid'], 'resources.*.id' => ['nullable','uuid'],
'resources.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'resources.*.source_date' => ['nullable','date_format:Y-m-d'],
'resources.*.kind' => ['nullable', 'in:infrastructure,service,access,other'], 'resources.*.kind' => ['nullable','in:infrastructure,service,access,other'],
'resources.*.scope' => ['nullable', 'in:inside,regional,unknown'], 'resources.*.scope' => ['nullable','in:inside,regional,unknown'],
'resources.*.capacity_type' => ['nullable', 'string', 'max:120'], 'resources.*.capacity_type' => ['nullable','string','max:120'],
'resources.*.capacity_value' => ['nullable', 'numeric', 'min:0'], 'resources.*.capacity_value' => ['nullable','numeric','min:0'],
'resources.*.capacity_unit' => ['nullable', 'string', 'max:40'], 'resources.*.capacity_unit' => ['nullable','string','max:40'],
'resources.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'], 'resources.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
'resources.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'], 'resources.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
'resources.*.support_document_ids' => ['sometimes', 'array', 'max:10'], 'resources.*.support_document_ids' => ['sometimes','array','max:10'],
'resources.*.support_document_ids.*' => ['uuid'], 'resources.*.support_document_ids.*' => ['uuid'],
'resources.*.private_note' => ['nullable', 'string', 'max:2000'], 'resources.*.private_note' => ['nullable','string','max:2000'],
'resources.*.name' => ['required', 'string', 'max:120'], 'resources.*.status' => ['required', 'in:existing,planned,unconfirmed'], 'resources.*.name' => ['required', 'string', 'max:120'], 'resources.*.status' => ['required', 'in:existing,planned,unconfirmed'],
'resources.*.source_reference' => ['nullable', 'string', 'max:2000'], 'data_as_of' => ['nullable', 'date_format:Y-m-d'], 'resources.*.source_reference' => ['nullable', 'string', 'max:2000'], 'data_as_of' => ['nullable', 'date_format:Y-m-d'],
'sources_consulted_at' => ['nullable', 'date_format:Y-m-d'], 'parent_zone_id' => ['nullable', 'uuid', 'exists:investment_zones,id'], 'sources_consulted_at' => ['nullable', 'date_format:Y-m-d'], 'parent_zone_id' => ['nullable', 'uuid', 'exists:investment_zones,id'],
@@ -270,36 +257,20 @@ class InvestmentZoneController extends Controller
$data['district'] = $path[1]->name ?? null; $data['district'] = $path[1]->name ?? null;
} }
$saved = $zones->save($id, $data, $request->user()->id); $saved=$zones->save($id, $data, $request->user()->id);
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved), $id ? 200 : 201); return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved), $id ? 200 : 201);
} }
public function transition(Request $request, InvestmentZones $zones, string $id, string $action): JsonResponse public function transition(Request $request, InvestmentZones $zones, string $id, string $action): JsonResponse
{ {
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version' => ['required_if:action,reconcile', 'nullable', 'integer', 'min:1']]); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version'=>['required_if:action,reconcile','nullable','integer','min:1']]);
if ($action === 'reconcile') { if($action==='reconcile'){
$request->validate(['base_public_version' => 'required|integer|min:1']); $request->validate(['base_public_version'=>'required|integer|min:1']);
$saved = DB::transaction(function () use ($id, $data, $request) { $saved=DB::transaction(function()use($id,$data,$request){$zone=InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();abort_unless($zone->category==='industrial_park',422);return app(InvestmentZoneOperationalProfile::class)->reconcile($zone,(int)$data['version_no'],(int)$data['base_public_version'],$request->user()->id);});
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); }else{$saved=$action === 'publish' ? $zones->publish($id,$data['version_no'],$request->user()->id) : $zones->suspend($id,$data['version_no'],$request->user()->id);}
abort_unless($zone->category === 'industrial_park', 422);
return app(InvestmentZoneOperationalProfile::class)->reconcile($zone, (int) $data['version_no'], (int) $data['base_public_version'], $request->user()->id);
});
} else {
$saved = $action === 'publish' ? $zones->publish($id, $data['version_no'], $request->user()->id) : $zones->suspend($id, $data['version_no'], $request->user()->id);
}
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved)); return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved));
} }
public function editorialReview(Request $request, string $id, string $action): JsonResponse
{
$data = $request->validate(['version_no' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:3000']);
return response()->json(app(InvestmentZoneOperationalProfile::class)->review($id, $request->user(), (int) $data['version_no'], $action, $data['reason']));
}
public function revisions(string $id): JsonResponse public function revisions(string $id): JsonResponse
{ {
InvestmentZone::findOrFail($id); InvestmentZone::findOrFail($id);
@@ -307,30 +278,6 @@ class InvestmentZoneController extends Controller
return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at'])); return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at']));
} }
public function revision(Request $request, string $id, int $version): JsonResponse
{
abort_unless(app(InvestmentZoneOperationalProfile::class)->reviewAccess($request->user()), 403);
InvestmentZone::findOrFail($id);
$revision = DB::table('investment_zone_revisions')->where('zone_id', $id)->where('version_no', $version)->first();
abort_unless($revision !== null, 404);
try {
$json = Crypt::decryptString($revision->snapshot);
abort_unless(hash_equals($revision->sha256, hash('sha256', $json)), 409);
$snapshot = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$fields = $snapshot['draft']['fields'] ?? $snapshot['public'] ?? $snapshot;
$details = $fields['operational_details'] ?? [];
if (isset($details['private_ciphertext'])) {
$details = json_decode(Crypt::decryptString($details['private_ciphertext']), true, 512, JSON_THROW_ON_ERROR);
}
} catch (\Throwable $e) {
abort(409, 'Não foi possível conferir esta versão. O histórico foi preservado.');
}
return response()->json(['version_no' => $revision->version_no, 'action' => $revision->action,
'fields' => Arr::only($fields, ['name', 'description']),
'observations' => array_map(fn ($row) => Arr::only($row, ['label', 'value', 'unit', 'scope', 'period_start', 'period_end', 'source_reference']), $details['integral']['observations'] ?? [])]);
}
public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse
{ {
InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers'); InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers');
@@ -3,8 +3,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\InvestmentZone; use App\Models\InvestmentZone;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -38,28 +36,14 @@ class InvestmentZoneRelationController extends Controller
DB::table('investment_zone_relations')->insert([...$data, 'id' => $relation, 'zone_id' => $id, DB::table('investment_zone_relations')->insert([...$data, 'id' => $relation, 'zone_id' => $id,
'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]); 'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($relation, $request->user()->id, 'created', 1); $this->audit($relation, $request->user()->id, 'created', 1);
if (app(PortalEditorialSubjects::class)->available()) {
app(PortalEditorialWorkflow::class)->saveSubject($request->user(), 'zone-relations', $relation, 0, [...$data, 'zone_id' => $id], true);
}
$record = DB::table('investment_zone_relations')->where('id', $relation)->first(); return response()->json(DB::table('investment_zone_relations')->where('id', $relation)->first(), 201);
if (app(PortalEditorialSubjects::class)->available()) {
$record->editorial_url = '/management/editorial/subjects/zone-relations/'.$relation;
$record->pending_publication = true;
}
return response()->json($record, 201);
}); });
} }
public function review(Request $request, string $id, string $action): JsonResponse public function review(Request $request, string $id, string $action): JsonResponse
{ {
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]); $data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
if (app(PortalEditorialSubjects::class)->writes('zone-relations', $id)) {
app(PortalEditorialSubjects::class)->authorize($request->user(), 'zone-relations', $id, true);
return response()->json(['pending_publication' => true, 'editorial_url' => '/management/editorial/subjects/zone-relations/'.$id]);
}
return DB::transaction(function () use ($request, $id, $action, $data) { return DB::transaction(function () use ($request, $id, $action, $data) {
$relation = DB::table('investment_zone_relations')->where('id', $id)->lockForUpdate()->first(); $relation = DB::table('investment_zone_relations')->where('id', $id)->lockForUpdate()->first();
@@ -2,106 +2,82 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Services\InvestmentComplements;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentOpportunityChoices;
use App\Services\InvestorContext; use App\Services\InvestorContext;
use App\Services\InvestorContextInstallation; use App\Services\InvestorContextInstallation;
use App\Services\InvestorPath; use App\Services\InvestorPath;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\Rule;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response;
final class InvestorContextController extends Controller final class InvestorContextController extends Controller
{ {
/**
* @return array<array-key, mixed>
*/
private function entryComplements(Request $request): array private function entryComplements(Request $request): array
{ {
$data = $request->validate(['complement_origin' => 'nullable|string|max:100', 'complement_code' => 'required_with:complement_origin|string|max:40', $data = $request->validate(['complement_origin' => 'nullable|string|max:100', 'complement_code' => 'required_with:complement_origin|string|max:40',
'complement_target' => 'nullable|string|max:100', 'complement_territory' => 'nullable|uuid']); 'complement_target' => 'nullable|string|max:100', 'complement_territory' => 'nullable|uuid']);
if (empty($data['complement_origin'])) { if (empty($data['complement_origin'])) return [];
return []; return app(\App\Services\InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'],
}
return app(InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'],
'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]); 'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]);
} }
/**
* @return array<array-key, mixed>
*/
private function entryChoices(Request $r, InvestorContext $context): array private function entryChoices(Request $r, InvestorContext $context): array
{ {
$data = $r->validate(['source' => 'nullable|string|max:100', 'from_dossier' => 'nullable|uuid', 'opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1', $data = $r->validate(['source' => 'nullable|string|max:100', 'from_dossier' => 'nullable|uuid', 'opportunities' => 'sometimes|array|max:100', 'opportunities.*' => 'integer|distinct|min:1',
'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']); 'province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']);
$source = ! empty($data['source']) ? $context->reference($data['source']) : null; $source = !empty($data['source']) ? $context->reference($data['source']) : null;
$ids = $data['opportunities'] ?? (($source['kind'] ?? '') === 'opportunity' ? [$source['id']] : []); $ids = $data['opportunities'] ?? (($source['kind'] ?? '') === 'opportunity' ? [$source['id']] : []);
abort_if(! empty($data['from_dossier']) && ! $ids, 422, 'Escolha a oportunidade associada à ficha da carteira.'); abort_if(!empty($data['from_dossier']) && !$ids, 422, 'Escolha a oportunidade associada à ficha da carteira.');
$path = app(InvestmentGeography::class)->selection($data); $path = app(InvestmentGeography::class)->selection($data);
$input = array_map(fn ($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids); $input = array_map(fn($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids);
if (! empty($data['from_dossier'])) { if (!empty($data['from_dossier'])) $input[0]['portfolio_origins'] = [$data['from_dossier']];
$input[0]['portfolio_origins'] = [$data['from_dossier']];
}
foreach ($this->entryComplements($r) as $complement) { foreach ($this->entryComplements($r) as $complement) {
$keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn ($key) => str_starts_with($key, 'opportunity:')); $keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn($key) => str_starts_with($key, 'opportunity:'));
foreach ($keys as $key) { foreach ($keys as $key) {
if (in_array($key, array_column($input, 'source'), true)) { if (in_array($key, array_column($input, 'source'), true)) continue;
continue;
}
$input[] = ['source' => $key, ...($complement['territory_id'] ? ['territory' => 'territory:'.$complement['territory_id']] : []), $input[] = ['source' => $key, ...($complement['territory_id'] ? ['territory' => 'territory:'.$complement['territory_id']] : []),
...(str_starts_with($complement['origin']['key'], 'portfolio:') ? ['portfolio_origins' => [substr($complement['origin']['key'], 10)]] : [])]; ...(str_starts_with($complement['origin']['key'], 'portfolio:') ? ['portfolio_origins' => [substr($complement['origin']['key'], 10)]] : [])];
} }
} }
return app(\App\Services\InvestmentOpportunityChoices::class)->resolve($input, [], true);
return app(InvestmentOpportunityChoices::class)->resolve($input, [], true);
} }
public function current(Request $r, InvestorContext $context): JsonResponse public function current(Request $r, InvestorContext $context)
{ {
return response()->json($context->current($r->user())); return response()->json($context->current($r->user()));
} }
public function focus(Request $r, InvestorContext $context): JsonResponse public function focus(Request $r, InvestorContext $context)
{ {
return response()->json($context->focus($r->user(), $r->all())); return response()->json($context->focus($r->user(), $r->all()));
} }
public function show(Request $r, string $id, InvestorContext $context): JsonResponse public function show(Request $r, string $id, InvestorContext $context)
{ {
$data = $r->validate(['opportunity' => 'nullable|string|max:100']); $data = $r->validate(['opportunity' => 'nullable|string|max:100']);
return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null)); return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null));
} }
public function options(Request $r, InvestorContext $context): JsonResponse public function options(Request $r, InvestorContext $context)
{ {
$d = $r->validate(['q' => 'nullable|string|max:120']); $d = $r->validate(['q' => 'nullable|string|max:120']);
return response()->json($context->options($d['q'] ?? '')); return response()->json($context->options($d['q'] ?? ''));
} }
public function corridorOptions(Request $r): JsonResponse public function corridorOptions(Request $r)
{ {
$data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']); $data = $r->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
$service = app(InvestmentCorridorContext::class); $service = app(\App\Services\InvestmentCorridorContext::class);
return response()->json(['corridors' => collect(config('investment_corridors'))->map(fn($entry,$key)=>['key'=>$key,'name'=>$entry['name'],'provinces'=>$service->provinces($key)])->values(),
return response()->json(['corridors' => collect(Config::array('investment_corridors'))->map(fn ($entry, $key) => ['key' => $key, 'name' => $entry['name'], 'provinces' => $service->provinces($key)])->values(),
'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]); 'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]);
} }
public function saveDraft(Request $r, string $id, InvestorContext $context): JsonResponse public function saveDraft(Request $r, string $id, InvestorContext $context)
{ {
return response()->json($context->saveDraft($r->user(), $id, $r->all())); return response()->json($context->saveDraft($r->user(), $id, $r->all()));
} }
public function edit(Request $r, string $id, InvestorContext $context): Response public function edit(Request $r, string $id, InvestorContext $context)
{ {
$data = $context->view($r->user(), $id); $data = $context->view($r->user(), $id);
abort_unless($data['can_write'], 403); abort_unless($data['can_write'], 403);
@@ -109,84 +85,72 @@ final class InvestorContextController extends Controller
$entries = $this->entryChoices($r, $context); $entries = $this->entryChoices($r, $context);
$choices = $entries ?: null; $choices = $entries ?: null;
$source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null; $source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null;
if ($choices === null && ($source['kind'] ?? '') === 'opportunity') { if ($choices === null && ($source['kind'] ?? '') === 'opportunity') $choices = app(\App\Services\InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]);
$choices = app(InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]); $explicitChoices=$choices;
} $selectionKey=$r->validate(['selection_key'=>'nullable|uuid'])['selection_key']??null;
$explicitChoices = $choices; $entryKey=fn($c)=>$c['opportunity']['key'].($selectionKey?'@'.$selectionKey:'');
$selectionKey = $r->validate(['selection_key' => 'nullable|uuid'])['selection_key'] ?? null; $entrySources=array_map($entryKey,$explicitChoices??[]);
$entryKey = fn ($c) => $c['opportunity']['key'].($selectionKey ? '@'.$selectionKey : ''); if ($choices !== null) $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices);
$entrySources = array_map($entryKey, $explicitChoices ?? []);
if ($choices !== null) {
$choices = app(InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices);
}
$draft = $context->draft($r->user(), $id); $draft = $context->draft($r->user(), $id);
if ($draft && isset($draft['opportunity_choices'])) { if ($draft && isset($draft['opportunity_choices'])) {
$recovered = app(InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true); $recovered = app(\App\Services\InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true);
$new = array_filter($explicitChoices ?? [], fn ($c) => ! in_array($entryKey($c), $draft['entry_sources'] ?? [], true)); $new=array_filter($explicitChoices??[],fn($c)=>!in_array($entryKey($c),$draft['entry_sources']??[],true));
$choices = app(InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new)); $choices = app(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new));
} }
$differences = []; $differences = [];
foreach ($explicitChoices ?? [] as $entry) { foreach ($explicitChoices ?? [] as $entry) {
$kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']); $kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']);
if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) { if ($kept && ($kept['territory']['key'] ?? null) !== ($entry['territory']['key'] ?? null)) $differences[] = ['title'=>$entry['opportunity']['title'],'saved'=>$kept['territory']['title']??'Por definir','requested'=>$entry['territory']['title']??'Por definir'];
$differences[] = ['title' => $entry['opportunity']['title'], 'saved' => $kept['territory']['title'] ?? 'Por definir', 'requested' => $entry['territory']['title'] ?? 'Por definir'];
}
} }
$complements = isset($draft['complements']) ? app(InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []); $complements = isset($draft['complements']) ? app(\App\Services\InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
$entryTerritory = null; $entryTerritory = null;
if (($source['kind'] ?? null) === 'portfolio') { if (($source['kind'] ?? null) === 'portfolio') {
$path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid'])); $path = app(InvestmentGeography::class)->selection($r->validate(['province_id' => 'nullable|uuid', 'district_id' => 'nullable|uuid', 'admin_post_id' => 'nullable|uuid', 'locality_id' => 'nullable|uuid']));
if ($path) { if ($path) $entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
$entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
}
} }
foreach ($this->entryComplements($r) as $entry) { foreach ($this->entryComplements($r) as $entry) {
$tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? '')); $tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? ''));
$entrySources[] = $tag; $entrySources[] = $tag;
if (! in_array($tag, $draft['entry_sources'] ?? [], true)) { if (!in_array($tag, $draft['entry_sources'] ?? [], true)) $complements = collect([...$complements, $entry])->unique('key')->values()->all();
$complements = collect([...$complements, $entry])->unique('key')->values()->all();
}
} }
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences'=>$differences,'entrySources'=>$entrySources,'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
return Inertia::render('investor-path/context', ['entryTerritory' => $entryTerritory, 'entryComplements' => $complements, 'entryLocationDifferences' => $differences, 'entrySources' => $entrySources, 'draft' => $draft, 'requestedChoices' => $choices, 'initial' => $data, 'options' => $context->options(), 'source' => $source, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['id']) : ($entryTerritory ? app(InvestmentGeography::class)->path($entryTerritory['id']) : [])]);
} }
public function preview(Request $r, string $id, InvestorContext $context): JsonResponse public function preview(Request $r, string $id, InvestorContext $context)
{ {
return response()->json($context->preview($r->user(), $id, $r->all())); return response()->json($context->preview($r->user(), $id, $r->all()));
} }
public function commit(Request $r, string $id, InvestorContext $context): JsonResponse public function commit(Request $r, string $id, InvestorContext $context)
{ {
return response()->json($context->commit($r->user(), $id, $r->all())); return response()->json($context->commit($r->user(), $id, $r->all()));
} }
public function start(Request $r, InvestorPath $path, InvestorContext $context): Response public function start(Request $r, InvestorPath $path, InvestorContext $context)
{ {
$d = $r->validate(['source' => 'nullable|string|max:100', 'step' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/']]); $d = $r->validate(['source' => 'nullable|string|max:100', 'step' => ['nullable', 'regex:/^S(0[1-9]|1[0-9])$/']]);
$ref = isset($d['source']) ? $context->reference($d['source']) : null; $ref = isset($d['source']) ? $context->reference($d['source']) : null;
$submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null; $submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null;
$entrySubmission = null; $entrySubmission = null;
if ($submissionId) { if ($submissionId) {
$submission = InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail(); $submission = \App\Models\InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail();
abort_unless(($ref['kind'] ?? null) === 'portfolio' && $submission->dossier_id === $ref['id'], 422, 'Conserve a ficha de origem da manifestação.'); abort_unless(($ref['kind'] ?? null) === 'portfolio' && $submission->dossier_id === $ref['id'], 422, 'Conserve a ficha de origem da manifestação.');
$entrySubmission = ['id' => $submission->id, 'version' => $submission->version]; $entrySubmission = ['id' => $submission->id, 'version' => $submission->version];
} }
$choices = $this->entryChoices($r, $context); $choices = $this->entryChoices($r, $context);
return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]); return Inertia::render('investor-path/context-start', ['entrySubmission' => $entrySubmission, 'entryComplements' => $this->entryComplements($r), 'entryQuery' => $r->only(['complement_origin', 'complement_code', 'complement_target', 'complement_territory', 'from_dossier', 'province_id', 'district_id', 'admin_post_id', 'locality_id']), 'opportunityChoices' => $choices, 'contexts' => $path->contexts($r->user()), 'source' => $ref, 'step' => $d['step'] ?? null]);
} }
public function installation(Request $r, InvestorContextInstallation $install): JsonResponse|Response public function installation(Request $r, InvestorContextInstallation $install)
{ {
$plan = $install->preview($r->user()); $plan = $install->preview($r->user());
return $r->expectsJson() ? response()->json($plan) : Inertia::render('investor-path/context-installation', ['plan' => $plan]); return $r->expectsJson() ? response()->json($plan) : Inertia::render('investor-path/context-installation', ['plan' => $plan]);
} }
public function install(Request $r, InvestorContextInstallation $install): JsonResponse public function install(Request $r, InvestorContextInstallation $install)
{ {
return response()->json($install->apply($r->user(), $r->all())); return response()->json($install->apply($r->user(),$r->all()));
} }
} }
+16 -66
View File
@@ -2,15 +2,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\CompanyComparison;
use App\Services\FintechInvestment; use App\Services\FintechInvestment;
use App\Services\InvestmentGuides;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestorPath; use App\Services\InvestorPath;
use App\Services\InvestmentZonePathContext;
use App\Services\LegacyContent; use App\Services\LegacyContent;
use App\Services\SectorResourceReferences;
use App\Services\SectorInvestment; use App\Services\SectorInvestment;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -20,22 +16,10 @@ use Inertia\Response;
class InvestorPathController extends Controller class InvestorPathController extends Controller
{ {
public function fiscalReport(Request $request, string $id, string $reportId, \App\Services\SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(app(InvestorPath::class)->available(), 404);
$request->validate(['opportunity' => 'nullable|string|max:150']);
$data = $drafts->dossierReport($request->user(), $id, $reportId, $request->query('opportunity'));
return response()->json($data)->header('Content-Disposition', 'attachment; filename="relatorio-fiscal-'.$reportId.'.json"')
->header('X-Content-Type-Options', 'nosniff');
}
public function index(Request $request, InvestorPath $path): JsonResponse|Response public function index(Request $request, InvestorPath $path): JsonResponse|Response
{ {
abort_unless($path->available(), 404); abort_unless($path->available(), 404);
$data = ['contexts' => $path->contexts($request->user()), $data = ['contexts' => $path->contexts($request->user())];
'companyComparisonAnswers' => $request->has('company_comparison') ? app(CompanyComparison::class)->answers(['answers' => $request->input('company_comparison')]) : null,
'guideSelection' => $request->has('investment_guide') ? app(InvestmentGuides::class)->selection($request->array('investment_guide')) : null];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data); return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
} }
@@ -44,9 +28,6 @@ class InvestorPathController extends Controller
{ {
abort_unless($path->available(), 404); abort_unless($path->available(), 404);
$data = ['contexts' => $path->contexts($request->user()), 'sectorSource' => app(SectorInvestment::class)->context($sectorSlug)]; $data = ['contexts' => $path->contexts($request->user()), 'sectorSource' => app(SectorInvestment::class)->context($sectorSlug)];
if (isset(SectorResourceReferences::SOURCES[$sectorSlug]) && $request->filled('resource_area')) {
$data['resourceSource'] = app(SectorResourceReferences::class)->selection($sectorSlug, ['area_id' => $request->query('resource_area'), 'location_label' => $request->query('resource_location'), 'group_id' => $request->query('resource_group'), 'source_sha256' => $request->query('resource_source')]);
}
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data); return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
} }
@@ -58,58 +39,36 @@ class InvestorPathController extends Controller
return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Setor associado ao mesmo dossier.'); return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Setor associado ao mesmo dossier.');
} }
public function zoneStart(Request $request, string $zoneId, InvestorPath $path): JsonResponse|Response public function zoneStart(Request $request,string $zoneId,InvestorPath $path): JsonResponse|Response
{ {
abort_unless($path->available(), 404); abort_unless($path->available(),404);
$data = $request->validate(['consulted_public_version' => 'required|integer|min:1']); $data=$request->validate(['consulted_public_version'=>'required|integer|min:1']);
$data = ['contexts' => $path->contexts($request->user()), 'zoneSource' => app(InvestmentZonePathContext::class)->context($zoneId, (int) $data['consulted_public_version'])]; $data=['contexts'=>$path->contexts($request->user()),'zoneSource'=>app(InvestmentZonePathContext::class)->context($zoneId,(int)$data['consulted_public_version'])];
return $request->expectsJson()?response()->json($data):Inertia::render('investor-path/index',$data);
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
} }
public function zoneAttach(Request $request, string $id): JsonResponse|RedirectResponse public function zoneAttach(Request $request,string $id): JsonResponse|RedirectResponse
{ {
app(InvestmentZonePathContext::class)->attach($request->user(), $id, $request->all()); app(InvestmentZonePathContext::class)->attach($request->user(),$id,$request->all());
return $request->expectsJson()?response()->json(['ok'=>true]):redirect('/investimentos/'.$id)->with('success','Referência do parque guardada; não constitui reserva ou autorização.');
return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência do parque guardada; não constitui reserva ou autorização.');
} }
public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse
{ {
$comparison = $request->has('company_comparison') ? app(CompanyComparison::class)->answers(['answers' => $request->input('company_comparison')]) : null; $zone=$request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null;
$guide = $request->has('investment_guide') ? app(InvestmentGuides::class)->selection($request->array('investment_guide')) : null; if($zone){ $request->validate(['confirm'=>'required|accepted']); app(InvestmentZonePathContext::class)->context($zone['zone_id'],(int)$zone['consulted_public_version']); }
abort_if($comparison !== null && $guide !== null, 422, 'Escolha o contexto de preparação que pretende continuar.');
$zone = $request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null;
if ($zone) {
$request->validate(['confirm' => 'required|accepted']);
app(InvestmentZonePathContext::class)->context($zone['zone_id'], (int) $zone['consulted_public_version']);
}
$sector = $request->validate(['sector_slug' => 'nullable|string|max:150'])['sector_slug'] ?? null; $sector = $request->validate(['sector_slug' => 'nullable|string|max:150'])['sector_slug'] ?? null;
if ($sector !== null) { if ($sector !== null) {
app(SectorInvestment::class)->context($sector); app(SectorInvestment::class)->context($sector);
} }
$reference = $request->input('sector_reference');
if ($reference !== null) {
abort_unless(isset(SectorResourceReferences::SOURCES[$sector]) && is_array($reference), 422);
app(SectorResourceReferences::class)->selection($sector, $reference);
}
$source = $request->input('content_resource_id'); $source = $request->input('content_resource_id');
if ($source !== null) { if ($source !== null) {
$resourceId = filter_var($source, FILTER_VALIDATE_INT); $resourceId = filter_var($source, FILTER_VALIDATE_INT);
abort_unless(is_int($resourceId) && $resourceId !== 0, 422); abort_unless(is_int($resourceId) && $resourceId !== 0, 422);
app(LegacyContent::class)->articleForResource($resourceId); app(LegacyContent::class)->articleForResource($resourceId);
} }
$id = DB::transaction(function () use ($path, $request, $source, $sector, $zone, $reference): string { $id = DB::transaction(function () use ($path, $request, $source, $sector, $zone): string {
if ($reference !== null) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->first();
}
$existingReferenceCase = $reference !== null ? DB::table('desk_cases')->where('user_id', $request->user()->id)->where('client_key', $request->input('client_key'))->first() : null;
$id = $path->create($request->user(), $request->all()); $id = $path->create($request->user(), $request->all());
if ($existingReferenceCase) {
$selection = app(SectorResourceReferences::class)->selection($sector, $reference);
$oldPayload = app(VirtualDesk::class)->decode($existingReferenceCase->payload);
abort_unless(array_any($oldPayload['investor_path']['sector_references'] ?? [], fn ($row) => $row['key'] === $selection['key']), 409, 'A chave de criação já foi usada com outra referência sectorial; recupere o dossier existente.');
}
if ($source !== null) { if ($source !== null) {
$case = $path->view($request->user(), $id)['case']; $case = $path->view($request->user(), $id)['case'];
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]); app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
@@ -117,23 +76,14 @@ class InvestorPathController extends Controller
if ($sector !== null) { if ($sector !== null) {
$case = $path->view($request->user(), $id)['case']; $case = $path->view($request->user(), $id)['case'];
app(SectorInvestment::class)->attach($request->user(), $id, ['version' => $case['version'], 'sector_slug' => $sector, 'sector_reference' => $reference]); app(SectorInvestment::class)->attach($request->user(), $id, ['version' => $case['version'], 'sector_slug' => $sector]);
}
if ($zone) {
$case = $path->view($request->user(), $id)['case'];
app(InvestmentZonePathContext::class)->attach($request->user(), $id, [...$zone, 'version' => $case['version'], 'confirm' => true]);
} }
if($zone){$case=$path->view($request->user(),$id)['case']; app(InvestmentZonePathContext::class)->attach($request->user(),$id,[...$zone,'version'=>$case['version'],'confirm'=>true]);}
return $id; return $id;
}); });
$href = $comparison === null ? '/investimentos/'.$id : '/investimentos/'.$id.'/forma-juridica?'.http_build_query(['answers' => $comparison]); return $request->expectsJson() ? response()->json(['id' => $id], 201) : redirect('/investimentos/'.$id)->with('success', 'Investimento guardado no seu dossier.');
if ($guide !== null) {
$href = '/investimentos/'.$id.'/guias/'.$guide['slug'].'?'.http_build_query(['current' => 1, 'selection' => 1, 'sha256' => $guide['sha256'], 'instruments' => $guide['instruments']]);
}
return $request->expectsJson() ? response()->json(['id' => $id, ...($comparison !== null ? ['comparison_href' => $href] : []), ...($guide !== null ? ['guide_href' => $href] : [])], 201) : redirect($href)->with('success', 'Investimento guardado no seu dossier.');
} }
public function show(Request $request, string $id, InvestorPath $path): JsonResponse|Response public function show(Request $request, string $id, InvestorPath $path): JsonResponse|Response
@@ -377,9 +377,6 @@ class LandAssistanceController extends Controller
'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)]; 'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)];
} }
/**
* @return array<array-key, mixed>|null
*/
private function dossierContext(LandAssistanceCase $case): ?array private function dossierContext(LandAssistanceCase $case): ?array
{ {
$id = $case->payload['investor_path_case_id'] ?? null; $id = $case->payload['investor_path_case_id'] ?? null;
@@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Services\InvestorPath; use App\Services\InvestorPath;
use App\Services\LegacyContent; use App\Services\LegacyContent;
use App\Services\PublicSearchMetrics;
use App\Services\VirtualDesk; use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -25,7 +24,6 @@ class LegacyContentController extends Controller
$articles = array_values(array_filter($articles, fn ($item) => $q === '' $articles = array_values(array_filter($articles, fn ($item) => $q === ''
|| str_contains(mb_strtolower(strip_tags($item['html'].' '.$item['title'])), mb_strtolower($q)))); || str_contains(mb_strtolower(strip_tags($item['html'].' '.$item['title'])), mb_strtolower($q))));
$match = $articles !== []; $match = $articles !== [];
app(PublicSearchMetrics::class)->candidate($request, 'sector_content', count($articles), ['q' => $q]);
return Inertia::render('portal/legacy', ['article' => $article, 'articles' => $articles, 'view' => 'landing', 'query' => $q, 'match' => $match]); return Inertia::render('portal/legacy', ['article' => $article, 'articles' => $articles, 'view' => 'landing', 'query' => $q, 'match' => $match]);
} }
@@ -1,72 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentGeography;
use App\Services\OfferPublicCatalogue;
use App\Services\OpportunityManagement;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class OfferPublicCatalogueController extends Controller
{
public function index(Request $request, OfferPublicCatalogue $catalogue): Response
{
$filters = $catalogue->filters($request);
return $this->uncached(Inertia::render('offers/catalogue', [...$catalogue->page($request, $filters), 'filters' => $filters,
'mapData' => in_array($filters['view'] ?? '', ['map', 'combined'], true) ? $catalogue->map($filters) : null,
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'references' => ['categories' => OpportunityManagement::CATEGORIES, 'options' => [], 'clusters' => [], 'corridors' => [], 'projects' => [], 'locations' => [], 'sectors' => []]])->toResponse($request));
}
public function show(Request $request, int $id, OfferPublicCatalogue $catalogue): Response
{
return $this->uncached(Inertia::render('offers/public-detail', ['offer' => $catalogue->detail($id)])->toResponse($request));
}
public function search(Request $request, OfferPublicCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->page($request, $catalogue->filters($request)))->header('Cache-Control', 'private, no-store');
}
public function detail(int $id, OfferPublicCatalogue $catalogue): JsonResponse
{
return response()->json(['data' => $catalogue->detail($id)])->header('Cache-Control', 'private, no-store');
}
public function map(Request $request, OfferPublicCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->map($catalogue->filters($request)))->header('Cache-Control', 'private, no-store');
}
public function export(Request $request, OfferPublicCatalogue $catalogue): StreamedResponse
{
$result = $catalogue->records($catalogue->filters($request));
return response()->streamDownload(function () use ($result) {
$stream = fopen('php://output', 'w');
if ($stream === false) {
return;
}
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, ['Título', 'Categoria', 'Localização', 'Preço', 'Moeda', 'Publicação', 'URL'], ';', '"', '');
foreach ($result['records'] as $record) {
$fields = [$record['title'], $record['category'], implode(' / ', array_column($record['location'], 'name')),
$record['price']['mode'] === 'negotiable' ? 'A negociar' : $record['price']['amount'], $record['price']['currency'], $record['published_at'], url($record['url'])];
fputcsv($stream, array_map(fn ($value) => preg_match('/^[\s]*[=+@-]/u', (string) $value) ? "'".$value : $value, $fields), ';', '"', '');
}
fclose($stream);
}, 'ofertas.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store', 'X-Export-Limit' => '1000', 'X-Results-Truncated' => $result['truncated'] ? 'true' : 'false']);
}
private function uncached(Response $response): Response
{
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
}
@@ -1,30 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\OpportunityCuration;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OpportunityCurationController extends Controller
{
public function show(Request $request, int $id, OpportunityCuration $service): Response
{
$base = str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities';
$filters = $request->validate(['page' => 'nullable|integer|min:1|max:1000000']);
return Inertia::render('management/opportunity-curation', [...$service->state($request->user(), $id, (int) ($filters['page'] ?? 1)), 'basePath' => $base]);
}
public function preview(Request $request, int $id, OpportunityCuration $service): JsonResponse
{
return response()->json($service->preview($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function confirm(Request $request, int $id, OpportunityCuration $service): JsonResponse
{
return response()->json($service->confirm($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
}
@@ -8,18 +8,13 @@ use App\Models\Sector;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\OpportunityCanonicalContract; use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityCanonicalResponse; use App\Services\OpportunityCanonicalResponse;
use App\Services\OpportunityCuration;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation; use App\Services\OpportunityManagementInstallation;
use App\Services\OpportunityMaturity; use App\Services\OpportunityMaturity;
use App\Services\OpportunityProfileWorkflow; use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityPromotion;
use App\Services\OpportunityResources; use App\Services\OpportunityResources;
use App\Services\OpportunityTechnicalIncidents;
use App\Services\OpportunityWorkflowTasks; use App\Services\OpportunityWorkflowTasks;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\ProactiveInvestment;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -81,8 +76,8 @@ class OpportunityManagementController extends Controller
$query->where('opportunity_category', $filters['category']); $query->where('opportunity_category', $filters['category']);
} }
if ($filters['sector_id'] ?? null) { if ($filters['sector_id'] ?? null) {
$sector = Sector::whereKey($filters['sector_id'])->first(); $sector = Sector::find($filters['sector_id']);
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} }
if ($filters['geography_scope'] ?? null) { if ($filters['geography_scope'] ?? null) {
$query->where('geography_scope', $filters['geography_scope']); $query->where('geography_scope', $filters['geography_scope']);
@@ -108,7 +103,7 @@ class OpportunityManagementController extends Controller
$records = $query->orderByDesc('id')->paginate(20)->withQueryString(); $records = $query->orderByDesc('id')->paginate(20)->withQueryString();
$records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) { $records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) {
$r->setAttribute('can_edit', $access->canEdit($request->user(), $r)); $r->setAttribute('can_edit', $access->canEdit($request->user(), $r));
$r->setAttribute('can_promote', app(OpportunityPromotion::class)->allowed($request->user(), $r)); $r->setAttribute('can_promote', app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $r));
$r->setAttribute('working_status', $access->state($r)); $r->setAttribute('working_status', $access->state($r));
$r->setAttribute('publication', $r->publicationPresentation()); $r->setAttribute('publication', $r->publicationPresentation());
$r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title); $r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title);
@@ -131,19 +126,16 @@ class OpportunityManagementController extends Controller
$access->authorize($request->user(), 'view', $record); $access->authorize($request->user(), 'view', $record);
} }
Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities', Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities',
'curationHref' => $record && app(OpportunityCuration::class)->allowed($request->user(), $record)
? (str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades/' : '/management/opportunities/').$record->id.'/destaque' : null,
'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null, 'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null,
'workflowState' => $record ? $access->state($record) : null, 'workflowState' => $record ? $access->state($record) : null,
'profileReauthenticationRequired' => $record && $record->owner_user_id !== null 'profileReauthenticationRequired' => $record && $record->owner_user_id !== null
? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false, ? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false,
'publicationState' => $record?->publicationPresentation(), 'publicationState' => $record?->publicationPresentation(),
'promotionHref' => $record && app(OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null, 'promotionHref' => $record && app(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null,
'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null, 'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null,
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false, 'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []], 'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []],
'workflowTasks' => $record && $record->owner_user_id !== null ? app(OpportunityWorkflowTasks::class)->listing($record) : [], 'workflowTasks' => $record && $record->owner_user_id !== null ? app(OpportunityWorkflowTasks::class)->listing($record) : [],
'workflowIncidents' => $record && $record->owner_user_id !== null ? app(OpportunityTechnicalIncidents::class)->recent($request->user(), $record) : [],
'profileMode' => $record ? $record->owner_user_id !== null : str_starts_with($request->path(), 'minhas-oportunidades'), 'profileMode' => $record ? $record->owner_user_id !== null : str_starts_with($request->path(), 'minhas-oportunidades'),
'profileActions' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->actions($request->user(), $record) : [], 'profileContext' => $record?->workflow_context, 'profileOrganization' => $record?->organization_id]); 'profileActions' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->actions($request->user(), $record) : [], 'profileContext' => $record?->workflow_context, 'profileOrganization' => $record?->organization_id]);
$values = $record ? $service->values($record) : []; $values = $record ? $service->values($record) : [];
@@ -155,7 +147,7 @@ class OpportunityManagementController extends Controller
Inertia::share('publicationPreview', app(OpportunityCanonicalContract::class)->publicProjection($values['canonical'] ?? [])); Inertia::share('publicationPreview', app(OpportunityCanonicalContract::class)->publicProjection($values['canonical'] ?? []));
$classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null; $classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null;
return Inertia::render('management/opportunity-draft', ['proactiveHistory' => app(ProactiveInvestment::class)->history($record), 'proactiveOptions' => app(ProactiveInvestment::class)->options($record), 'resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES, return Inertia::render('management/opportunity-draft', ['proactiveHistory' => app(\App\Services\ProactiveInvestment::class)->history($record), 'proactiveOptions' => app(\App\Services\ProactiveInvestment::class)->options($record), 'resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'categories' => OpportunityManagement::CATEGORIES,
'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES, 'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS], 'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES, 'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS],
'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true), 'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true),
'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]); 'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]);
@@ -4,24 +4,22 @@ namespace App\Http\Controllers;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\OpportunityPromotion; use App\Services\OpportunityPromotion;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response;
final class OpportunityPromotionController extends Controller final class OpportunityPromotionController extends Controller
{ {
public function show(Request $request, int $id, OpportunityPromotion $service): Response public function show(Request $request, int $id, OpportunityPromotion $service): \Inertia\Response
{ {
return Inertia::render('management/opportunity-promotion', $service->editor($request->user(), Opportunity::findOrFail($id))); return Inertia::render('management/opportunity-promotion', $service->editor($request->user(), Opportunity::findOrFail($id)));
} }
public function preview(Request $request, int $id, OpportunityPromotion $service): JsonResponse public function preview(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
{ {
return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all())); return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all()));
} }
public function confirm(Request $request, int $id, OpportunityPromotion $service): JsonResponse public function confirm(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
{ {
return response()->json($service->confirm($request->user(), $id, $request->all())); return response()->json($service->confirm($request->user(), $id, $request->all()));
} }
@@ -6,13 +6,9 @@ use App\Models\Opportunity;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\OpportunityImageProcessor; use App\Services\OpportunityImageProcessor;
use App\Services\OpportunityLibrarySupport;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceInstallation; use App\Services\OpportunityResourceInstallation;
use App\Services\OpportunityResources; use App\Services\OpportunityResources;
use App\Services\OpportunityTechnicalExecution;
use App\Services\OpportunityTechnicalIncidents;
use App\Services\OpportunityTerritorialPresentation;
use App\Services\OrganizationCountryInstallation; use App\Services\OrganizationCountryInstallation;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\VerifiedDocumentBinary; use App\Services\VerifiedDocumentBinary;
@@ -77,7 +73,7 @@ class OpportunityResourceController extends Controller
return hash('sha256', json_encode([$request->user()->getAuthIdentifier(), $plan['target'], $plan['migration'], $plan['code_hash']], JSON_THROW_ON_ERROR)); return hash('sha256', json_encode([$request->user()->getAuthIdentifier(), $plan['target'], $plan['migration'], $plan['code_hash']], JSON_THROW_ON_ERROR));
} }
public function index(int $id, OpportunityResources $service): \Symfony\Component\HttpFoundation\Response public function index(int $id, OpportunityResources $service): Response
{ {
$opportunity = Opportunity::withTrashed()->findOrFail($id); $opportunity = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity); app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
@@ -102,15 +98,12 @@ class OpportunityResourceController extends Controller
} }
} }
Inertia::encryptHistory(); return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => !app(\App\Services\PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(\App\Services\OpportunityLibrarySupport::class)->choices(request()->user()), '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']), 'locationReference' => app(\App\Services\OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]);
return tap(Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'technicalIncidents' => app(OpportunityTechnicalIncidents::class)->recent(request()->user(), $opportunity), 'librarySources' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(OpportunityLibrarySupport::class)->choices(request()->user()), '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']), 'locationReference' => app(OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)])->toResponse(request()), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
} }
public function reuseLibrary(Request $request, int $id): JsonResponse public function reuseLibrary(Request $request, int $id): JsonResponse
{ {
$row = app(OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all()); $row = app(\App\Services\OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all());
return response()->json(['id' => $row->id, 'message' => 'Original associado como suporte privado, sem copiar ficheiros ou publicar.']); return response()->json(['id' => $row->id, 'message' => 'Original associado como suporte privado, sem copiar ficheiros ou publicar.']);
} }
@@ -120,18 +113,9 @@ class OpportunityResourceController extends Controller
if (is_string($input['payload'] ?? null)) { if (is_string($input['payload'] ?? null)) {
$input['payload'] = json_decode($input['payload'], true); $input['payload'] = json_decode($input['payload'], true);
} }
$operation = function () use ($request, $id, $kind, $input, $resourceId, $service) { $record = $service->save($request->user(), $id, $kind, $input, $request->file('file'), $resourceId);
$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); 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);
};
if (! $request->hasFile('file') || ! in_array($kind, ['documents', 'images'], true)) {
return $operation();
}
$before = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize($request->user(), 'edit', $before);
return app(OpportunityTechnicalExecution::class)->run($request->user(), $before, $kind === 'images' ? 'upload_image' : 'upload_document', $input, $operation);
} }
public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service): JsonResponse public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service): JsonResponse
@@ -143,14 +127,9 @@ class OpportunityResourceController extends Controller
public function prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse public function prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse
{ {
$before = Opportunity::withTrashed()->findOrFail($id); $service->prepare($request->user(), $id, $resourceId, $request->all());
app(PortalProfileAccess::class)->authorize($request->user(), 'edit', $before);
return app(OpportunityTechnicalExecution::class)->run($request->user(), $before, 'prepare_image', $request->all(), function () use ($request, $id, $resourceId, $service) { return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
$service->prepare($request->user(), $id, $resourceId, $request->all());
return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
});
} }
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response|BinaryFileResponse public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response|BinaryFileResponse
@@ -171,18 +150,15 @@ class OpportunityResourceController extends Controller
{ {
abort_unless(in_array($kind, ['documents', 'images'], true), 404); abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$opportunity = Opportunity::findOrFail($id); $opportunity = Opportunity::findOrFail($id);
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity); app(\App\Services\PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
abort_if($opportunity->status === 'archived', 404); 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.*']); $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 !== null, 404, 'Recurso indisponível ou excluído logicamente.'); abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.');
abort_unless($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.'); abort_unless($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.');
$support = DB::table($service->table($kind))->where('id', $resourceId)->first(); $support = DB::table($service->table($kind))->where('id', $resourceId)->first();
app(OpportunityLibrarySupport::class)->assertAvailable($support, $record); app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($support, $record);
if (request()->filled('source_sha256')) { if (request()->filled('source_sha256')) abort_unless(hash_equals(trim($record->sha256), (string)request('source_sha256')), 409, 'O original já não corresponde à versão consultada.');
abort_unless(hash_equals(trim($record->sha256), (string) request('source_sha256')), 409, 'O original já não corresponde à versão consultada.');
}
return app(VerifiedDocumentBinary::class)->respond($record); return app(VerifiedDocumentBinary::class)->respond($record);
} }
@@ -2,35 +2,24 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\Sector; use App\Models\User;
use App\Services\InvestmentCatalogue; use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations; use App\Services\InvestmentRelations;
use App\Services\PartnershipAgreementHistory;
use App\Services\PartnershipAgreementProjection;
use App\Services\PartnershipAgreements;
use App\Services\PartnershipCandidacies;
use App\Services\PartnershipCandidacyHistory;
use App\Services\PartnershipDraftSaves;
use App\Services\PartnershipExchange; use App\Services\PartnershipExchange;
use App\Services\PartnershipMatching; use App\Services\PartnershipMatching;
use App\Services\PartnershipMilestones;
use App\Services\PartnershipNotifications;
use App\Services\PartnershipPublication;
use App\Services\PartnershipPublicationHistory;
use App\Services\PartnershipRoomUploads;
use App\Services\PartnershipSources; use App\Services\PartnershipSources;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary; use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -56,11 +45,9 @@ class PartnershipExchangeController extends Controller
foreach (['sector_id', 'partnership_type', 'kind'] as $field) { foreach (['sector_id', 'partnership_type', 'kind'] as $field) {
if (! empty($filters[$field])) { if (! empty($filters[$field])) {
if ($field === 'sector_id') { if ($field === 'sector_id') {
$sector = Sector::whereKey($filters[$field])->first(); $sector = \App\Models\Sector::find($filters[$field]);
$query->whereIn('p.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('p.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} else { } else { $query->where('p.'.$field, $filters[$field]); }
$query->where('p.'.$field, $filters[$field]);
}
} }
} }
if (! empty($filters['q'])) { if (! empty($filters['q'])) {
@@ -91,7 +78,6 @@ class PartnershipExchangeController extends Controller
} }
$results = $query->orderByDesc('p.id')->select(['p.id', 'p.title', 'p.sector', 'p.province', 'p.kind', 'p.partnership_type', 'p.sector_id', 'p.deadline', 'p.published_at']) $results = $query->orderByDesc('p.id')->select(['p.id', 'p.title', 'p.sector', 'p.province', 'p.kind', 'p.partnership_type', 'p.sector_id', 'p.deadline', 'p.published_at'])
->selectRaw("p.payload->>'summary' as summary")->paginate(12)->withQueryString(); ->selectRaw("p.payload->>'summary' as summary")->paginate(12)->withQueryString();
app(PublicSearchMetrics::class)->candidate($request, 'partnerships', $results->total(), $filters);
if ($request->expectsJson()) { if ($request->expectsJson()) {
return response()->json($results); return response()->json($results);
} }
@@ -129,23 +115,16 @@ class PartnershipExchangeController extends Controller
public function dashboard(Request $request): Response public function dashboard(Request $request): Response
{ {
$actor = $request->user(); $actor = $request->user();
$exchange = app(PartnershipExchange::class); $organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$organizations = $exchange->readableOrganizations($actor);
$mine = DB::table('portal_partnerships')->where(fn ($q) => $q->where(fn ($own) => $own->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->latest()->limit(100)->get(); $mine = DB::table('portal_partnerships')->where(fn ($q) => $q->where(fn ($own) => $own->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->latest()->limit(100)->get();
$interests = DB::table('partnership_interests as i')->join('portal_partnerships as p', 'p.id', '=', 'i.partnership_id') $interests = DB::table('partnership_interests as i')->join('portal_partnerships as p', 'p.id', '=', 'i.partnership_id')
->leftJoin('partnership_rooms as room', 'room.interest_id', '=', 'i.id') ->leftJoin('partnership_rooms as room', 'room.interest_id', '=', 'i.id')
->where(fn ($q) => $q->where(fn ($own) => $own->where('i.user_id', $actor->id)->whereNull('i.organization_id'))->orWhereIn('i.organization_id', $organizations)) ->where(fn ($q) => $q->where(fn ($own) => $own->where('i.user_id', $actor->id)->whereNull('i.organization_id'))->orWhereIn('i.organization_id', $organizations))
->select(['i.id', 'i.partnership_id', 'i.status', 'i.decision_reason', 'p.title', 'p.status as partnership_status', 'p.user_id', 'p.organization_id', 'room.id as room_id'])->orderByDesc('i.id')->limit(100)->get() ->select(['i.id', 'i.partnership_id', 'i.status', 'i.decision_reason', 'p.title', 'room.id as room_id'])->orderByDesc('i.id')->limit(100)->get();
->map(function ($interest) use ($actor, $exchange) {
$canRead = $interest->partnership_status === 'published' || $exchange->owns($actor, $interest, false) || Gate::forUser($actor)->allows('manage-portal');
return ['id' => $interest->id, 'partnership_id' => $interest->partnership_id, 'status' => $interest->status, 'decision_reason' => $interest->decision_reason,
'title' => $canRead ? $interest->title : null, 'partnership_href' => $canRead ? '/parcerias/'.$interest->partnership_id : null, 'room_id' => $interest->room_id];
});
$moderation = Gate::forUser($actor)->allows('manage-portal') ? DB::table('portal_partnerships')->whereIn('status', ['submitted', 'pending'])->orderBy('updated_at')->get(['id', 'title', 'status', 'version']) : []; $moderation = Gate::forUser($actor)->allows('manage-portal') ? DB::table('portal_partnerships')->whereIn('status', ['submitted', 'pending'])->orderBy('updated_at')->get(['id', 'title', 'status', 'version']) : [];
return Inertia::render('partnerships/dashboard', ['mine' => $mine->map(fn ($p) => ['id' => $p->id, 'title' => $p->title, 'status' => $p->status, 'version' => $p->version]), 'interests' => $interests, 'moderation' => $moderation, return Inertia::render('partnerships/dashboard', ['mine' => $mine->map(fn ($p) => ['id' => $p->id, 'title' => $p->title, 'status' => $p->status, 'version' => $p->version]), 'interests' => $interests, 'moderation' => $moderation,
'notifications' => app(PartnershipNotifications::class)->forUser($actor)]); 'notifications' => DB::table('partnership_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(50)->get()]);
} }
public function edit(Request $request, ?int $id = null): Response public function edit(Request $request, ?int $id = null): Response
@@ -158,10 +137,7 @@ class PartnershipExchangeController extends Controller
$origins = [['type' => $source['type'], 'target_id' => $source['target_id']]]; $origins = [['type' => $source['type'], 'target_id' => $source['target_id']]];
} }
$payload = $record ? json_decode($record->payload, true) : []; return Inertia::render('partnerships/editor', ['record' => $record ? [...(array) $record, 'payload' => json_decode($record->payload, true)] : null, 'origins' => $origins,
unset($payload['_creation_hash']);
return Inertia::render('partnerships/editor', ['editorActorId' => $request->user()->id, 'saveCommands' => $record ? app(PartnershipDraftSaves::class)->commands($request->user(), $record) : [], 'record' => $record ? [...(array) $record, 'payload' => $payload] : null, 'origins' => $origins,
'locations' => $record ? DB::table('partnership_locations')->where('partnership_id', $id)->get() : [], ...$this->catalogues($request)]); 'locations' => $record ? DB::table('partnership_locations')->where('partnership_id', $id)->get() : [], ...$this->catalogues($request)]);
} }
@@ -169,7 +145,7 @@ class PartnershipExchangeController extends Controller
{ {
$record = app(PartnershipExchange::class)->save($request->user(), $request->all(), $id); $record = app(PartnershipExchange::class)->save($request->user(), $request->all(), $id);
return $request->expectsJson() ? response()->json(['id' => $record->id, 'version' => $record->version, 'status' => $record->status, 'replayed' => (bool) ($record->command_replayed ?? false), 'saved_version' => $record->command_saved_version ?? null]) : redirect('/parcerias/'.$record->id.'/editar')->with('success', 'Rascunho guardado.'); return $request->expectsJson() ? response()->json(['id' => $record->id, 'version' => $record->version, 'status' => $record->status]) : redirect('/parcerias/'.$record->id.'/editar')->with('success', 'Rascunho guardado.');
} }
public function show(Request $request, int $id): Response|JsonResponse public function show(Request $request, int $id): Response|JsonResponse
@@ -183,23 +159,17 @@ class PartnershipExchangeController extends Controller
$privateDetails = ($payload['confidentiality'] ?? 'public') === 'private' && ! $owner && ! $moderator; $privateDetails = ($payload['confidentiality'] ?? 'public') === 'private' && ! $owner && ! $moderator;
$mine = $request->user() ? DB::table('partnership_interests')->where('partnership_id', $id)->get()->filter(fn ($i) => app(PartnershipExchange::class)->owns($request->user(), $i, false))->values() : collect(); $mine = $request->user() ? DB::table('partnership_interests')->where('partnership_id', $id)->get()->filter(fn ($i) => app(PartnershipExchange::class)->owns($request->user(), $i, false))->values() : collect();
$interests = $owner ? DB::table('partnership_interests')->where('partnership_id', $id)->orderBy('id')->get() : $mine; $interests = $owner ? DB::table('partnership_interests')->where('partnership_id', $id)->orderBy('id')->get() : $mine;
$commands = $request->user() ? app(PartnershipCandidacies::class)->commands($request->user(), $id) : []; $interestData = $interests->map(function ($interest) use ($record) {
$interestData = $interests->map(function ($interest) use ($record, $request, $commands) {
return [...(array) $interest, 'payload' => json_decode($interest->payload, true), 'assessment' => json_decode($interest->assessment, true), return [...(array) $interest, 'payload' => json_decode($interest->payload, true), 'assessment' => json_decode($interest->assessment, true),
'can_edit' => $request->user() && app(PartnershipExchange::class)->owns($request->user(), $interest), 'commands' => $commands[(int) $interest->id] ?? [],
'match' => app(PartnershipMatching::class)->explain($record, $interest), 'room_id' => DB::table('partnership_rooms')->where('interest_id', $interest->id)->value('id')]; 'match' => app(PartnershipMatching::class)->explain($record, $interest), 'room_id' => DB::table('partnership_rooms')->where('interest_id', $interest->id)->value('id')];
}); });
$props = ['record' => ['id' => $record->id, 'title' => $record->title, 'status' => $record->status, 'version' => $record->version, 'kind' => $record->kind, $props = ['record' => ['id' => $record->id, 'title' => $record->title, 'status' => $record->status, 'version' => $record->version, 'kind' => $record->kind,
'partnership_type' => $record->partnership_type, 'sector' => $record->sector, 'province' => $record->province, 'sector_id' => $record->sector_id, 'deadline' => $record->deadline, 'partnership_type' => $record->partnership_type, 'sector' => $record->sector, 'province' => $record->province, 'sector_id' => $record->sector_id, 'deadline' => $record->deadline,
'offer' => $privateDetails ? null : $record->offer, 'seeking' => $privateDetails ? null : $record->seeking, 'payload' => $privateDetails ? ['summary' => $payload['summary'] ?? '', 'confidentiality' => 'private'] : $payload], 'offer' => $privateDetails ? null : $record->offer, 'seeking' => $privateDetails ? null : $record->seeking, 'payload' => $privateDetails ? ['summary' => $payload['summary'] ?? '', 'confidentiality' => 'private'] : $payload],
'owner' => (bool) $owner, 'canEdit' => $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record), 'moderator' => (bool) $moderator, 'owner' => (bool) $owner, 'canEdit' => $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record), 'moderator' => (bool) $moderator,
'acceptingInterests' => app(PartnershipCandidacies::class)->accepting($record),
'canStartInterest' => $request->user() && ! $owner && ! DB::table('partnership_interests')->where('partnership_id', $id)->where('user_id', $request->user()->id)->exists(),
'origins' => app(PartnershipSources::class)->forPartnership($id, $owner ? $request->user() : null), 'interests' => $interestData, 'origins' => app(PartnershipSources::class)->forPartnership($id, $owner ? $request->user() : null), 'interests' => $interestData,
'locations' => app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $id)->get()->map(fn ($l) => ['id' => $l->geo_location_id, 'role' => $l->role, 'source_reference' => $l->source_reference])->all())), 'locations' => app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $id)->get()->map(fn ($l) => ['id' => $l->geo_location_id, 'role' => $l->role, 'source_reference' => $l->source_reference])->all())),
'historySummary' => ($owner || $moderator) ? app(PartnershipPublicationHistory::class)->summary($request->user(), $id) : null, 'history' => ($owner || $moderator) ? DB::table('partnership_events')->where('partnership_id', $id)->where('event_type', 'like', 'partnership_opportunity.%')->orderBy('occurred_at')->get(['event_type', 'actor_id', 'occurred_at', 'payload']) : [],
'publicationActions' => app(PartnershipPublication::class)->actions($request->user(), $record),
'publicationCommands' => app(PartnershipPublication::class)->commands($request->user(), $record),
...$this->catalogues($request)]; ...$this->catalogues($request)];
if ($request->user()) { if ($request->user()) {
Inertia::encryptHistory(); Inertia::encryptHistory();
@@ -208,74 +178,39 @@ class PartnershipExchangeController extends Controller
return $request->expectsJson() ? response()->json($props)->header('Cache-Control', 'private, no-store') : Inertia::render('partnerships/detail', $props); return $request->expectsJson() ? response()->json($props)->header('Cache-Control', 'private, no-store') : Inertia::render('partnerships/detail', $props);
} }
public function publicationHistory(Request $request, int $id): Response|JsonResponse
{
$data = $request->validate(['before' => 'nullable|string|max:4000']);
$history = app(PartnershipPublicationHistory::class)->page($request->user(), $id, $data['before'] ?? null);
return $request->expectsJson() ? response()->json($history) : Inertia::render('partnerships/publication-history', $history);
}
public function transition(Request $request, int $id): RedirectResponse|JsonResponse public function transition(Request $request, int $id): RedirectResponse|JsonResponse
{ {
$data = $request->validate(['client_key' => 'nullable|uuid', 'version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000']); $data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000']);
$record = app(PartnershipExchange::class)->transition($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['client_key'] ?? null); $record = app(PartnershipExchange::class)->transition($request->user(), $id, $data['version'], $data['action'], $data['reason']);
return $request->expectsJson() ? response()->json(['id' => $record->id, 'status' => $record->status, 'version' => $record->version, 'replayed' => (bool) ($record->command_replayed ?? false)]) : back()->with('success', 'Decisão registada.'); return $request->expectsJson() ? response()->json(['status' => $record->status, 'version' => $record->version]) : back()->with('success', 'Decisão registada.');
} }
public function interest(Request $request, int $id): RedirectResponse|JsonResponse public function interest(Request $request, int $id): RedirectResponse|JsonResponse
{ {
$interest = app(PartnershipExchange::class)->interest($request->user(), $id, $request->all()); $interest = app(PartnershipExchange::class)->interest($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['id' => $interest->id, 'version' => $interest->version, 'replayed' => (bool) ($interest->command_replayed ?? false)]) : back()->with('success', 'Interesse guardado.'); return $request->expectsJson() ? response()->json(['id' => $interest->id, 'version' => $interest->version]) : back()->with('success', 'Interesse guardado.');
} }
public function decide(Request $request, int $id): RedirectResponse|JsonResponse public function decide(Request $request, int $id): RedirectResponse|JsonResponse
{ {
$data = $request->validate(['client_key' => 'nullable|uuid', 'partnership_version' => 'nullable|integer|min:1', 'weights_hash' => 'nullable|string|regex:/^[a-f0-9]{64}$/', 'version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000', 'assessment' => 'array|max:7']); $data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000', 'assessment' => 'array|max:7']);
$interest = app(PartnershipExchange::class)->decideInterest($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['assessment'] ?? [], $data['partnership_version'] ?? null, $data['weights_hash'] ?? null, $data['client_key'] ?? null); $interest = app(PartnershipExchange::class)->decideInterest($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['assessment'] ?? []);
return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version, 'replayed' => (bool) ($interest->command_replayed ?? false)]) : back()->with('success', 'Candidatura atualizada.'); return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version]) : back()->with('success', 'Candidatura atualizada.');
}
public function candidacyHistory(Request $request, int $id): Response|JsonResponse
{
$data = $request->validate(['before' => 'nullable|string|max:4000']);
$history = app(PartnershipCandidacyHistory::class)->page($request->user(), $id, $data['before'] ?? null);
return $request->expectsJson() ? response()->json($history) : Inertia::render('partnerships/interest-history', $history);
}
public function agreementHistory(Request $request, int $id): Response|JsonResponse
{
$data = $request->validate(['before' => 'nullable|string|max:4000']);
$history = app(PartnershipAgreementHistory::class)->page($request->user(), $id, $data['before'] ?? null);
return $request->expectsJson() ? response()->json($history) : Inertia::render('partnerships/agreement-history', $history);
} }
public function room(Request $request, int $id): Response|JsonResponse public function room(Request $request, int $id): Response|JsonResponse
{ {
$context = app(PartnershipExchange::class)->room($request->user(), $id); $context = app(PartnershipExchange::class)->room($request->user(), $id);
$participants = app(PartnershipExchange::class)->participants($context['partnership'], $context['interest']); $props = [...$context, 'room' => [...(array) $context['room'], 'agreement' => $context['room']->agreement ? json_decode($context['room']->agreement, true) : null],
$participantIds = array_column($participants, 'id'); 'messages' => DB::table('partnership_messages as m')->join('users as u', 'u.id', '=', 'm.author_id')->where('room_id', $id)->orderBy('m.id')->get(['m.id', 'm.body', 'm.created_at', 'm.author_id', 'u.name']),
$canWrite = app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest']);
$canAssign = $canWrite && in_array($context['room']->status, ['agreement_recorded', 'agreed', 'execution'], true);
$creationKeys = app(PartnershipMilestones::class)->creationKeys($request->user(), (int) $context['partnership']->id, $id);
$partnership = (array) $context['partnership'];
$payload = json_decode($partnership['payload'], true, 512, JSON_THROW_ON_ERROR);
unset($payload['_creation_hash']);
$partnership['payload'] = json_encode($payload, JSON_THROW_ON_ERROR);
$props = [...$context, 'partnership' => $partnership, 'room' => [...(array) $context['room'], 'agreement' => app(PartnershipAgreementProjection::class)->current($context['room']->agreement)],
'messages' => DB::table('partnership_messages as m')->join('users as u', 'u.id', '=', 'm.author_id')->where('room_id', $id)->orderBy('m.id')->get(['m.id', 'm.body', 'm.created_at', 'm.author_id', 'm.client_key', 'u.name'])
->map(fn ($message) => [...(array) $message, 'client_key' => (int) $message->author_id === $request->user()->id ? $message->client_key : null]),
'documents' => DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('room_id', $id) 'documents' => DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('room_id', $id)
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id)) ->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))
->get(['link.id', 'link.shared', 'link.owner_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.id as document_id']), ->get(['link.id', 'link.shared', 'link.owner_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.id as document_id']),
'milestones' => DB::table('partnership_milestones as m')->leftJoin('users as responsible', 'responsible.id', '=', 'm.responsible_id')->where('m.room_id', $id)->orderBy('m.id') 'milestones' => DB::table('partnership_milestones')->where('room_id', $id)->orderBy('id')->get(),
->get(['m.*', 'responsible.name as responsible_name'])->map(fn ($milestone) => [...(array) $milestone, 'responsible_is_participant' => in_array((int) $milestone->responsible_id, $participantIds, true), 'client_key' => $creationKeys[(int) $milestone->id] ?? null]), 'canWrite' => app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest'])];
'agreementActorId' => $request->user()->id, 'agreementParty' => app(PartnershipAgreements::class)->party($request->user(), $context), 'agreementCommands' => app(PartnershipAgreements::class)->commands($request->user(), $context), 'participants' => $canAssign ? $participants : [], 'canWrite' => $canWrite];
return $request->expectsJson() ? response()->json($props) : Inertia::render('partnerships/room', $props); return $request->expectsJson() ? response()->json($props) : Inertia::render('partnerships/room', $props);
} }
@@ -284,10 +219,9 @@ class PartnershipExchangeController extends Controller
{ {
$data = $request->validate(['client_key' => 'required|uuid', 'body' => 'required|string|max:15000']); $data = $request->validate(['client_key' => 'required|uuid', 'body' => 'required|string|max:15000']);
DB::transaction(function () use ($request, $id, $data) { DB::transaction(function () use ($request, $id, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id); $context = app(PartnershipExchange::class)->room($request->user(), $id, true);
abort_unless(app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest']), 403);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first(); $locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null, 404); abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
$key = ['room_id' => $id, 'author_id' => $request->user()->id, 'client_key' => $data['client_key']]; $key = ['room_id' => $id, 'author_id' => $request->user()->id, 'client_key' => $data['client_key']];
$existing = DB::table('partnership_messages')->where($key)->first(); $existing = DB::table('partnership_messages')->where($key)->first();
if ($existing) { if ($existing) {
@@ -295,7 +229,6 @@ class PartnershipExchangeController extends Controller
return; return;
} }
abort_if(in_array($locked->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
DB::table('partnership_messages')->insert([...$key, 'body' => $data['body']]); DB::table('partnership_messages')->insert([...$key, 'body' => $data['body']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.message_created.v1', ['room_id' => $id], 'Nova mensagem privada', [$context['partnership']->user_id, $context['interest']->user_id]); app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.message_created.v1', ['room_id' => $id], 'Nova mensagem privada', [$context['partnership']->user_id, $context['interest']->user_id]);
}); });
@@ -305,31 +238,100 @@ class PartnershipExchangeController extends Controller
public function agreement(Request $request, int $id): RedirectResponse|JsonResponse public function agreement(Request $request, int $id): RedirectResponse|JsonResponse
{ {
$result = app(PartnershipAgreements::class)->act($request->user(), $id, $request->all()); $data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|in:record,confirm,start,complete,cancel,renew', 'title' => 'required_if:action,record|nullable|string|max:200',
'terms' => 'required_if:action,record|nullable|string|max:20000', 'reason' => 'required|string|max:2000']);
DB::transaction(function () use ($request, $id, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$room = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless((int) $room->version === (int) $data['version'], 409, 'Acordo alterado.');
$agreement = $room->agreement ? json_decode($room->agreement, true) : null;
$status = $room->status;
if ($data['action'] === 'record') {
abort_unless(in_array($status, ['negotiation', 'agreement_recorded'], true), 409);
$previous = $agreement['previous_versions'] ?? [];
if ($agreement) {
$previous[] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations'], 'recorded_at' => $agreement['recorded_at']];
}
$agreement = ['title' => $data['title'], 'terms' => $data['terms'], 'recorded_by' => $request->user()->id, 'recorded_at' => now()->toIso8601String(), 'confirmations' => [],
'parties' => [$context['partnership']->user_id, $context['interest']->user_id], 'legal_signature_status' => 'not_verified', 'previous_versions' => $previous];
$status = 'agreement_recorded';
} elseif ($data['action'] === 'confirm') {
abort_unless($status === 'agreement_recorded' && $agreement, 409);
$party = app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) ? 'proponent' : 'candidate';
$agreement['confirmations'][$party] = ['actor_id' => $request->user()->id, 'date' => now()->toIso8601String(), 'reason' => $data['reason']];
if (count($agreement['confirmations']) === 2) {
$status = 'agreed';
}
} else {
$allowed = ['start' => ['agreed'], 'complete' => ['execution'], 'cancel' => ['negotiation', 'agreement_recorded', 'agreed', 'execution'], 'renew' => ['agreed', 'execution']];
abort_unless(in_array($status, $allowed[$data['action']] ?? [], true), 409);
if ($data['action'] === 'complete') {
abort_if(DB::table('partnership_milestones')->where('room_id', $id)->where('status', '!=', 'completed')->exists(), 409, 'Existem marcos pendentes.');
}
$status = ['start' => 'execution', 'complete' => 'closed', 'cancel' => 'cancelled', 'renew' => 'negotiation'][$data['action']];
if ($data['action'] === 'renew') {
$agreement['previous_versions'][] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations']];
$agreement['confirmations'] = [];
}
}
DB::table('partnership_rooms')->where('id', $id)->update(['agreement' => $agreement ? json_encode($agreement) : null, 'status' => $status, 'version' => $room->version + 1, 'updated_at' => now()]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_agreement.changed.v1', ['room_id' => $id, 'status' => $status, 'version' => $room->version + 1, 'reason' => $data['reason']], 'Acordo: '.$status, [$context['partnership']->user_id, $context['interest']->user_id]);
});
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Acordo atualizado; assinatura jurídica não verificada.'); return $request->expectsJson() ? response()->json(['recorded' => true]) : back()->with('success', 'Acordo atualizado; assinatura jurídica não verificada.');
} }
public function milestone(Request $request, int $id, ?int $milestone = null): RedirectResponse|JsonResponse public function milestone(Request $request, int $id, ?int $milestone = null): RedirectResponse|JsonResponse
{ {
$data = $request->validate(['client_key' => 'nullable|uuid', 'title' => 'required|string|max:200', 'responsible_id' => 'required|integer', 'due_date' => 'nullable|date_format:Y-m-d', 'status' => 'required|in:pending,progress,completed', $data = $request->validate(['title' => 'required|string|max:200', 'responsible_id' => 'required|integer', 'due_date' => 'nullable|date_format:Y-m-d', 'status' => 'required|in:pending,progress,completed',
'evidence' => 'required_if:status,completed|nullable|string|max:10000', 'version' => ($milestone ? 'required' : 'nullable').'|integer|min:1']); 'evidence' => 'required_if:status,completed|nullable|string|max:10000', 'version' => ($milestone ? 'required' : 'nullable').'|integer|min:1']);
if (isset($data['client_key'])) { DB::transaction(function () use ($request, $id, $milestone, $data) {
$data['client_key'] = strtolower($data['client_key']); $context = app(PartnershipExchange::class)->room($request->user(), $id, true);
} $locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
$result = app(PartnershipMilestones::class)->save($request->user(), $id, $data, $milestone); abort_unless($locked !== null && in_array($locked->status, ['agreement_recorded', 'agreed', 'execution'], true), 409);
$responsible = User::query()->whereKey($data['responsible_id'])->first();
abort_unless($responsible !== null && (app(PartnershipExchange::class)->owns($responsible, $context['partnership']) || app(PartnershipExchange::class)->owns($responsible, $context['interest'])), 422, 'Responsável deve ser participante identificado.');
if ($milestone) {
$existing = DB::table('partnership_milestones')->where('room_id', $id)->where('id', $milestone)->lockForUpdate()->first();
abort_unless($existing !== null, 404);
abort_unless((int) $existing->version === (int) $data['version'], 409);
DB::table('partnership_milestones')->where('id', $milestone)->update([...$data, 'version' => $existing->version + 1, 'updated_at' => now()]);
} else {
unset($data['version']);
$milestone = DB::table('partnership_milestones')->insertGetId([...$data, 'room_id' => $id, 'created_at' => now(), 'updated_at' => now()]);
}
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_milestone.changed.v1', ['milestone_id' => $milestone, 'status' => $data['status']], 'Marco atualizado', [$context['partnership']->user_id, $context['interest']->user_id]);
});
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Marco guardado.'); return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Marco guardado.');
} }
public function upload(Request $request, int $id): RedirectResponse|JsonResponse public function upload(Request $request, int $id): RedirectResponse|JsonResponse
{ {
$data = $request->validate(['client_key' => 'nullable|uuid', 'file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'shared' => 'required|boolean']); $data = $request->validate(['file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'shared' => 'required|boolean']);
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$file = $request->file('file'); $file = $request->file('file');
abort_unless($file instanceof UploadedFile, 422); $documentId = (string) Str::uuid();
$result = app(PartnershipRoomUploads::class)->save($request->user(), $id, $file, (bool) $data['shared'], isset($data['client_key']) ? strtolower($data['client_key']) : null); $stored = $file->storeAs('partnerships/'.$id, $documentId, 'local');
abort_unless(is_string($stored) && $stored !== '', 503);
try {
DB::transaction(function () use ($request, $id, $data, $file, $documentId, $stored, $context) {
app(PartnershipExchange::class)->room($request->user(), $id, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409);
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'partnership-exchange', 'data_classification' => 'restricted',
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored,
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id]);
DB::table('partnership_documents')->insert(['room_id' => $id, 'document_id' => $documentId, 'owner_id' => $request->user()->id, 'shared' => $data['shared']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_added.v1', ['room_id' => $id, 'document_id' => $documentId, 'shared' => $data['shared']], 'Documento privado anexado');
});
} catch (\Throwable $e) {
Storage::disk('local')->delete($stored);
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Envio reconciliado com o documento privado. Consulte o estado da análise na lista.')->with('uploaded_document_id', $result['document_id']); return $request->expectsJson() ? response()->json(['uploaded' => true]) : back()->with('success', 'Documento privado guardado; análise antimalware pendente.');
} }
public function download(Request $request, int $id, int $attachment): \Illuminate\Http\Response|BinaryFileResponse public function download(Request $request, int $id, int $attachment): \Illuminate\Http\Response|BinaryFileResponse
@@ -361,7 +363,7 @@ class PartnershipExchangeController extends Controller
public function notification(Request $request, int $id): RedirectResponse public function notification(Request $request, int $id): RedirectResponse
{ {
app(PartnershipNotifications::class)->markRead($request->user(), $id); abort_unless(DB::table('partnership_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]) > 0, 404);
return back(); return back();
} }
@@ -391,7 +393,7 @@ class PartnershipExchangeController extends Controller
} }
} }
return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), return ['types' => config('partnerships.types'), 'criteria' => config('partnerships.criteria'), 'sources' => $sources, 'sectors' => \App\Models\Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']), 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']),
'organizations' => $request->user() ? DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id) 'organizations' => $request->user() ? DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id)
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']) : []]; ->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']) : []];
@@ -1,265 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\ContentManagement;
use App\Services\DocumentManagementAccess;
use App\Services\PortalEditorialNativeNotices;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\TerritorialRegistry;
use App\Services\VerifiedDocumentBinary;
use App\Support\CheckedFile;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
final class PortalEditorialWorkflowController extends Controller
{
public function subjects(Request $request, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
abort_unless($adapter->available() && $request->user()->can('manage-portal') && ! app(PortalProfileAccess::class)->has($request->user(), 'auditor'), 403);
$items = $workflow->subjectQueue()->orderByDesc('e.updated_at')->paginate(25, ['e.id', 'e.subject_kind', 'e.subject_key', 'e.revision_no', 'e.state', DB::raw("e.payload->'resource'->>'title' AS title")]);
return $this->screen($request, 'management/editorial-subjects', ['items' => $items]);
}
public function subject(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id);
$record = $adapter->query($kind, $id)->firstOrFail();
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestSubject($kind, $id);
$editions = DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $id);
$edition = $selected ? (clone $editions)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = $edition ? $workflow->requiresIndependent($edition) : $adapter->independent($kind);
$canApprove = ! $independent || ! in_array($request->user()->id, $payload['contributors'] ?? [], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'structural', 'subjectKind' => $kind, 'editUrl' => $adapter->editUrl($kind, $record), 'subjectFields' => $adapter->form($kind), 'subjectInitial' => Arr::only(get_object_vars($record), $adapter->definition($kind)['fields']),
'resource' => ['id' => $id, 'title' => $record->name ?? $record->title ?? $kind, 'published' => ($record->deleted_at ?? null) === null && ($record->validation_status ?? null) !== 'revoked' && (clone $editions)->where('state', 'published')->exists(), 'public_edition_no' => (clone $editions)->where('state', 'published')->max('revision_no')],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => $independent, 'canApprove' => $canApprove, 'manager' => true, 'legalReviewer' => false,
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->subject_kind', $kind)->where('after_data->subject_key', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => $editions->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function subjectTransition(Request $request, string $kind, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), false, false, $kind);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
}
public function subjectSave(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id, true);
$data = $request->validate(['expected_revision' => 'required|integer|min:1', 'fields' => 'required|array', 'withdraw' => 'sometimes|boolean']);
$edition = $workflow->saveSubject($request->user(), $kind, $id, (int) $data['expected_revision'], $adapter->validate($kind, $data['fields']), false, $data['withdraw'] ?? null);
return $request->expectsJson() ? response()->json(['edition' => $edition], 201)->header('Cache-Control', 'private, no-store') : redirect('/management/editorial/subjects/'.$kind.'/'.$id)->with('success', 'Proposta privada guardada.');
}
public function subjectChoices(Request $request, string $kind, string $id): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id);
$data = $request->validate(['field' => 'required|string|max:80', 'q' => 'nullable|string|max:100']);
return response()->json(['data' => $adapter->choices($kind, $data['field'], $data['q'] ?? null), 'limit' => 100])->header('Cache-Control', 'private, no-store');
}
private const KIND = "CASE WHEN e.payload->'resource'->>'legislation' = 'true' OR e.payload->'resource'->>'document_type' = 'legislation' THEN 'legislation' WHEN e.payload->'resource'->>'editorial_content' IS NOT NULL THEN 'article' ELSE 'document' END";
/** @param array<string,mixed> $data */
private function screen(Request $request, string $component, array $data): Response
{
$response = $request->expectsJson() ? response()->json($data) : Inertia::render($component, $data)->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
public function index(Request $request, PortalEditorialWorkflow $workflow): Response
{
abort_unless($workflow->enabled(), 503);
$actor = $request->user();
$access = app(DocumentManagementAccess::class);
$query = $workflow->queue($actor);
$filters = array_filter($request->validate(['state' => 'nullable|in:draft,submitted,in_review,approved,published,returned,rejected,archived', 'kind' => 'nullable|in:article,legislation,document']), fn ($value) => $value !== null && $value !== '');
if (isset($filters['state'])) {
$query->where('e.state', $filters['state']);
}
if (isset($filters['kind'])) {
$query->whereRaw(self::KIND.' = ?', [$filters['kind']]);
}
$items = $query->orderByDesc('e.updated_at')->paginate(25, ['r.id', DB::raw("e.payload->'resource'->>'title' AS title"), 'r.document_type', 'r.published', 'e.id as edition_id', 'e.revision_no', 'e.state', 'e.legal_required', 'u.name as responsible'])->withQueryString();
$destinations = app(ContentManagement::class)->access($actor);
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null,
'offersUrl' => isset($destinations['opportunities']['index']) && app(PortalProfileAccess::class)->available() ? '/minhas-oportunidades/ofertas' : null,
'offerOperationsUrl' => ! app(PortalProfileAccess::class)->has($actor, 'auditor') && (app(PortalProfileAccess::class)->has($actor, 'validator') || app(PortalProfileAccess::class)->has($actor, 'publisher')) ? '/minhas-oportunidades/ofertas?operational=1' : null,
'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null,
'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor), 'demographyUrl' => $destinations['demography']['index'] ?? null, 'demographyPending' => $workflow->demographyPending($actor),
'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null,
'nativePending' => app(PortalEditorialNativeNotices::class)->pending($actor), 'zonesUrl' => app(PortalEditorialNativeNotices::class)->access($actor, 'investment-zones') ? '/management/investment/zones' : null,
'subjectsUrl' => app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal') ? '/management/editorial/subjects' : null, 'subjectsPending' => $workflow->subjectPending($actor)];
return $this->screen($request, 'management/editorial-workflow', $data);
}
public function show(Request $request, int $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorize($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latest($id);
$e = $selected ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $e, 404);
$payload = $e ? $workflow->payload($e) : null;
$independent = ! $e || ! $workflow->requiresIndependent($e) || ! in_array($request->user()->id, $payload['contributors'], true);
$data = ['resource' => ['id' => $id, 'title' => $resource->title, 'published' => (bool) $resource->published, 'public_edition_no' => $resource->published ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('state', 'published')->max('revision_no') : null], 'edition' => $e ? [...(array) $e, 'payload' => $payload, 'fresh' => $e->state === 'published' || $workflow->fresh($e)] : null,
'current' => $e && $e->id === $latest?->id,
'independentRequired' => $e ? $workflow->requiresIndependent($e) : false,
'canApprove' => $independent && app(DocumentManagementAccess::class)->full($request->user()),
'manager' => app(DocumentManagementAccess::class)->full($request->user()),
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'legalReviewer' => $workflow->legalReviewer($request->user()),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->resource_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])];
return $this->screen($request, 'management/editorial-edition', $data);
}
public function transition(Request $request, int $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all());
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
}
public function portfolio(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizePortfolio($request->user(), $id);
$record = DB::table('investment_dossiers')->where('id', $id)->first();
abort_unless($record !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestPortfolio($id);
$edition = $selected ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = ! $edition || ! in_array($request->user()->id, $payload['contributors'], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'portfolio',
'resource' => ['id' => $id, 'title' => $record->title, 'published' => $record->publication_status === 'published', 'public_edition_no' => $record->publication_status === 'published' ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('state', 'published')->max('revision_no') : null],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => true, 'canApprove' => $independent, 'manager' => true, 'legalReviewer' => false,
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->dossier_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function portfolioTransition(Request $request, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), true);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial da carteira registada.');
}
public function demography(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizeDemography($request->user(), $id);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestDemography($id);
$edition = $selected ? DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = ! $edition || ! $workflow->requiresIndependent($edition) || ! in_array($request->user()->id, $payload['contributors'], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'demography',
'resource' => ['id' => $id, 'title' => $batch->file_name, 'published' => $batch->status === 'approved', 'public_edition_no' => $batch->status === 'approved' ? DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->where('state', 'published')->max('revision_no') : null],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => $edition ? $workflow->requiresIndependent($edition) : false, 'canApprove' => $independent, 'manager' => true, 'legalReviewer' => false,
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->demographic_import_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function demographyDraft(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$data = $request->validate(['expected_revision' => 'required|integer|min:0']);
$edition = $workflow->saveDemography($request->user(), $id, (int) $data['expected_revision']);
return $request->expectsJson() ? response()->json(['edition' => $edition], 201)->header('Cache-Control', 'private, no-store') : redirect('/management/editorial/demography/'.$id);
}
public function demographyTransition(Request $request, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), false, true);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial do lote registada.');
}
public function demographyOriginal(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorizeDemography($request->user(), $id);
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch !== null, 404);
$path = Storage::disk('local')->path('demography/'.$id.'/original.xlsx');
abort_unless(CheckedFile::matches($path, trim($batch->sha256)), 409, 'Original indisponível ou alterado.');
return response()->download($path, basename($batch->file_name), ['Cache-Control' => 'private, no-store', 'X-Robots-Tag' => 'noindex, nofollow']);
}
public function file(Request $request, int $id, string $edition, string $document, PortalEditorialWorkflow $workflow): Response
{
$workflow->authorize($request->user(), $id);
$e = DB::table('portal_editorial_editions')->where('id', $edition)->where('resource_id', $id)->first();
abort_unless($e !== null, 404);
$file = null;
foreach ($workflow->payload($e)['files'] as $entry) {
if ($entry['id'] === $document) {
$file = $entry;
break;
}
}
abort_unless($file !== null, 404);
abort_unless($workflow->canReadFile($request->user(), $document), 403);
$record = DB::table('documents')->where('id', $document)->first();
abort_unless($record && hash_equals(trim($file['sha256']), trim($record->sha256)) && (int) $file['version_no'] === (int) $record->version_no, 409, 'O anexo mudou; esta edição conserva a referência anterior.');
return $this->binary($record);
}
private function binary(\stdClass $record): Response
{
$response = app(VerifiedDocumentBinary::class)->respond($record);
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
return $response;
}
public function markRead(Request $request, int $notice, PortalEditorialWorkflow $workflow): Response
{
abort_unless($workflow->enabled(), 503);
$n = DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->first();
abort_unless($n !== null, 404);
abort_unless($workflow->visibleNotice($request->user(), $n), 404);
DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]);
return $request->expectsJson() ? response()->json(['read' => true]) : back();
}
}
@@ -15,8 +15,6 @@ use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\PortalAudit; use App\Services\PortalAudit;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\ScopedPortalAdministration;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -57,38 +55,32 @@ class PortalManagementController extends Controller
private function interestAssignmentEtag(InterestRequest $interest): string private function interestAssignmentEtag(InterestRequest $interest): string
{ {
return hash('sha256', json_encode([$interest->id, $interest->status, $interest->assigned_to, $interest->updated_at?->format('Y-m-d H:i:s.u')], JSON_THROW_ON_ERROR)); return hash('sha256',json_encode([$interest->id,$interest->status,$interest->assigned_to,$interest->updated_at?->format('Y-m-d H:i:s.u')],JSON_THROW_ON_ERROR));
} }
public function interestAssignments(Request $request): Response|JsonResponse public function interestAssignments(Request $request): Response|JsonResponse
{ {
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403); abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
$rows = InterestRequest::whereNotIn('status', ['cancelled', 'closed', 'refused', 'converted'])->orderBy('id')->get(['id', 'reference', 'status', 'assigned_to', 'updated_at']) $rows=InterestRequest::whereNotIn('status',['cancelled','closed','refused','converted'])->orderBy('id')->get(['id','reference','status','assigned_to','updated_at'])
->map(fn ($row) => ['id' => $row->id, 'protocol' => $row->reference, 'status' => $row->status, 'analyst_id' => $row->assigned_to, 'version' => 0, 'etag' => $this->interestAssignmentEtag($row), 'due_at' => null]); ->map(fn($row)=>['id'=>$row->id,'protocol'=>$row->reference,'status'=>$row->status,'analyst_id'=>$row->assigned_to,'version'=>0,'etag'=>$this->interestAssignmentEtag($row),'due_at'=>null]);
$data = ['kind' => 'interest', 'requests' => $rows, 'analysts' => $this->interestAssignees()->get(['id', 'name'])]; $data=['kind'=>'interest','requests'=>$rows,'analysts'=>$this->interestAssignees()->get(['id','name'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
} }
public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{ {
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403); abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
$data = $request->validate(['etag' => 'required|string|size:64', 'analyst_id' => ['required', 'integer', Rule::in($this->interestAssignees()->pluck('id'))], 'reason' => 'required|string|min:10|max:2000'], ['reason.required' => 'Explique o motivo da atribuição.', 'reason.min' => 'Explique o motivo da atribuição com pelo menos 10 caracteres.', 'analyst_id.required' => 'Seleccione o responsável.', 'analyst_id.in' => 'Seleccione um responsável autorizado.']); $data=$request->validate(['etag'=>'required|string|size:64','analyst_id'=>['required','integer',Rule::in($this->interestAssignees()->pluck('id'))],'reason'=>'required|string|min:10|max:2000'],['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 10 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.in'=>'Seleccione um responsável autorizado.']);
DB::transaction(function () use ($request, $interest, $data) { DB::transaction(function()use($request,$interest,$data){
$row = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail(); $row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
if (! hash_equals($this->interestAssignmentEtag($row), $data['etag'])) { if (!hash_equals($this->interestAssignmentEtag($row),$data['etag'])) throw ValidationException::withMessages(['etag'=>'A atribuição mudou. Recupere a lista actual e tente novamente.']);
throw ValidationException::withMessages(['etag' => 'A atribuição mudou. Recupere a lista actual e tente novamente.']); if (in_array($row->status,['cancelled','closed','refused','converted'],true)) throw ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
} $owner=(int)($row->metadata['portal_user_id']??0);
if (in_array($row->status, ['cancelled', 'closed', 'refused', 'converted'], true)) { abort_if($owner===(int)$data['analyst_id'] || InvestmentSubmission::where('interest_request_id',$row->id)->where('user_id',$data['analyst_id'])->exists(),422,'O interessado não pode acompanhar o seu próprio pedido como equipa.');
throw ValidationException::withMessages(['status' => 'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']); PortalAudit::save($row,['assigned_to'=>(int)$data['analyst_id']],$request->user()->id);
} app(\App\Services\PortalProfileAccess::class)->audit($request->user(),'interest_assigned',['interest_id'=>$row->id,'assigned_to'=>(int)$data['analyst_id'],'reason'=>$data['reason']]);
$owner = (int) ($row->metadata['portal_user_id'] ?? 0);
abort_if($owner === (int) $data['analyst_id'] || InvestmentSubmission::where('interest_request_id', $row->id)->where('user_id', $data['analyst_id'])->exists(), 422, 'O interessado não pode acompanhar o seu próprio pedido como equipa.');
PortalAudit::save($row, ['assigned_to' => (int) $data['analyst_id']], $request->user()->id);
app(PortalProfileAccess::class)->audit($request->user(), 'interest_assigned', ['interest_id' => $row->id, 'assigned_to' => (int) $data['analyst_id'], 'reason' => $data['reason']]);
}); });
return $request->expectsJson()?response()->json(['ok'=>true]):back()->with('success','Responsável atribuído.');
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Responsável atribuído.');
} }
public function interests(Request $request): Response public function interests(Request $request): Response
@@ -103,7 +95,7 @@ class PortalManagementController extends Controller
$query = InterestRequest::query()->with('assignee:id,name')->select([ $query = InterestRequest::query()->with('assignee:id,name')->select([
'id', 'reference', 'full_name', 'email', 'country', 'metadata', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at', 'id', 'reference', 'full_name', 'email', 'country', 'metadata', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
]); ]);
app(ScopedPortalAdministration::class)->interestQuery($query, $request->user()); app(\App\Services\ScopedPortalAdministration::class)->interestQuery($query, $request->user());
if ($country = $filters['country'] ?? null) { if ($country = $filters['country'] ?? null) {
$values = app(CountryCatalogue::class)->filterValues($country); $values = app(CountryCatalogue::class)->filterValues($country);
$query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values); $query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values);
@@ -150,7 +142,7 @@ class PortalManagementController extends Controller
public function interest(InterestRequest $interest): Response public function interest(InterestRequest $interest): Response
{ {
abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403); abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id) $history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)
->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at']) ->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
->map(function ($row) { ->map(function ($row) {
@@ -178,7 +170,7 @@ class PortalManagementController extends Controller
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse
{ {
abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403); abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$validated = $request->validate([ $validated = $request->validate([
'status' => ['required', Rule::in(self::STATES)], 'status' => ['required', Rule::in(self::STATES)],
'note' => ['nullable', 'string', 'min:10', 'max:2000'], 'note' => ['nullable', 'string', 'min:10', 'max:2000'],
@@ -206,7 +198,7 @@ class PortalManagementController extends Controller
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{ {
abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403); abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]); $data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent'); $this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent');
@@ -216,7 +208,7 @@ class PortalManagementController extends Controller
public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{ {
abort_unless(app(ScopedPortalAdministration::class)->interest(request()->user(), $interest), 403); abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]); $data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest'); $this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
@@ -309,8 +301,8 @@ class PortalManagementController extends Controller
} }
} }
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) { if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
$sector = Sector::whereKey($filters['sector_id'])->first(); $sector = Sector::find($filters['sector_id']);
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} }
return Inertia::render('management/catalog', [ return Inertia::render('management/catalog', [
@@ -327,7 +319,7 @@ class PortalManagementController extends Controller
public function edit(string $resource, ?int $id = null): Response public function edit(string $resource, ?int $id = null): Response
{ {
$model = $resource === 'sectors' ? Sector::class : Opportunity::class; $model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::whereKey($id)->firstOrFail() : null; $record = $id ? $model::findOrFail($id) : null;
return Inertia::render('management/editor', [ return Inertia::render('management/editor', [
'resource' => $resource, 'resource' => $resource,
@@ -343,7 +335,7 @@ class PortalManagementController extends Controller
throw ValidationException::withMessages(['status' => 'Use Gestão de Oportunidades para guardar uma versão de rascunho; publicação não é uma edição.']); throw ValidationException::withMessages(['status' => 'Use Gestão de Oportunidades para guardar uma versão de rascunho; publicação não é uma edição.']);
} }
$model = $resource === 'sectors' ? Sector::class : Opportunity::class; $model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::whereKey($id)->firstOrFail() : new $model; $record = $id ? $model::findOrFail($id) : new $model;
$rules = $resource === 'sectors' ? [ $rules = $resource === 'sectors' ? [
'name' => ['required', 'string', 'max:150'], 'name' => ['required', 'string', 'max:150'],
'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)], 'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)],
@@ -2,14 +2,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exceptions\OpportunityWorkflowUnavailable;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityCanonicalContract; use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityContacts; use App\Services\OpportunityContacts;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\OpportunityTechnicalSupport; use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityWorkflowExecution;
use App\Services\PortalInternalDesignation; use App\Services\PortalInternalDesignation;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration; use App\Services\PortalProfileAdministration;
@@ -51,15 +48,9 @@ class PortalProfileController extends Controller
{ {
$service->administrative($request->user()); $service->administrative($request->user());
return Inertia::render('profiles/administration', ['technicalSupportAvailable' => app(OpportunityTechnicalSupport::class)->installed(), 'requests' => DB::table('portal_profile_requests')->latest()->get(), return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(),
'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(), 'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(), 'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(),
'staffAccounts' => User::query()->select(['id', 'name', 'email', 'email_verified_at'])
->selectRaw('CASE WHEN two_factor_secret IS NOT NULL AND two_factor_confirmed_at IS NOT NULL THEN 1 ELSE 0 END AS mfa_enrolled')
->withCasts(['mfa_enrolled' => 'boolean'])->orderBy('name')->get()->map(fn (User $user) => [
'id' => $user->id, 'name' => $user->name, 'email' => $user->email,
'verified' => $user->email_verified_at !== null, 'mfa' => $user->getAttribute('mfa_enrolled'),
]),
'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES, 'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES,
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES, 'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']), 'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
@@ -152,14 +143,9 @@ class PortalProfileController extends Controller
return app(OpportunityManagementController::class)->edit($request, app(OpportunityManagement::class), $id); return app(OpportunityManagementController::class)->edit($request, app(OpportunityManagement::class), $id);
} }
public function workflow(Request $request, int $id, OpportunityWorkflowExecution $service): JsonResponse public function workflow(Request $request, int $id, OpportunityProfileWorkflow $service): JsonResponse
{ {
try { $record = $service->transition($request->user(), $id, $request->all());
$record = $service->transition($request->user(), $id, $request->all());
} catch (OpportunityWorkflowUnavailable $failure) {
return response()->json(['message' => 'Não foi possível confirmar o resultado. Consulte o estado da oportunidade antes de repetir. '.($failure->recorded ? 'Incidente técnico registado. ' : 'Não foi possível guardar o incidente; conserve esta referência. ').'Referência: '.$failure->reference,
'reference' => $failure->reference, 'incident_recorded' => $failure->recorded], 503)->header('Cache-Control', 'private, no-store');
}
return response()->json(['id' => $id, 'status' => app(PortalProfileAccess::class)->state($record), 'publication_status' => $record->status, 'etag' => app(OpportunityManagement::class)->etag($record), 'message' => 'Ação interna registada; não representa decisão externa.']); return response()->json(['id' => $id, 'status' => app(PortalProfileAccess::class)->state($record), 'publication_status' => $record->status, 'etag' => app(OpportunityManagement::class)->etag($record), 'message' => 'Ação interna registada; não representa decisão externa.']);
} }
@@ -7,7 +7,6 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
class PortalProfileInstallationController extends Controller class PortalProfileInstallationController extends Controller
{ {
@@ -23,19 +22,6 @@ class PortalProfileInstallationController extends Controller
return response()->json($installation->apply($request->user(), $request->all())); return response()->json($installation->apply($request->user(), $request->all()));
} }
public function supportPreview(Request $request, PortalProfileInstallation $installation): HttpResponse
{
$plan = $installation->supportPreview($request->user());
Inertia::encryptHistory();
return tap($request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/support-installation', $plan)->toResponse($request), static fn (HttpResponse $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function applySupport(Request $request, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->applySupport($request->user(), $request->all()))->header('Cache-Control', 'private, no-store');
}
public function bootstrap(Request $request, PortalProfileInstallation $installation): JsonResponse public function bootstrap(Request $request, PortalProfileInstallation $installation): JsonResponse
{ {
return response()->json($installation->bootstrap($request->user(), $request->all())); return response()->json($installation->bootstrap($request->user(), $request->all()));
@@ -44,7 +30,6 @@ class PortalProfileInstallationController extends Controller
public function bootstrapPreview(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse|Response public function bootstrapPreview(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse|Response
{ {
$plan = $installation->bootstrapPreview($request->user(), $id); $plan = $installation->bootstrapPreview($request->user(), $id);
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan); return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan);
} }
@@ -4,22 +4,13 @@ namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware; use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest; use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\DocumentManagementAccess;
use App\Services\EconomicReferenceCatalog;
use App\Services\EditorialReading;
use App\Services\IdempotencyConflict; use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission; use App\Services\InterestSubmission;
use App\Services\InvestmentGeography;
use App\Services\LegacyContent; use App\Services\LegacyContent;
use App\Services\Malware\MalwareScanner;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\PublicDocumentPresentation; use App\Services\Malware\MalwareScanner;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary; use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk; use App\Services\VirtualDesk;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
@@ -27,7 +18,6 @@ use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -36,7 +26,6 @@ use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
class PortalServicesController extends Controller class PortalServicesController extends Controller
{ {
@@ -81,7 +70,7 @@ class PortalServicesController extends Controller
]); ]);
$props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()]; $props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()];
if ($page === 'sectors') { if ($page === 'sectors') {
$props['sectors'] = SectorOrganization::catalog(); $props['sectors'] = \App\Services\SectorOrganization::catalog();
} }
if (in_array($page, ['documents', 'legislation'], true)) { if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources(); $query = $this->resources();
@@ -127,9 +116,8 @@ class PortalServicesController extends Controller
'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.last_reviewed_at', 'r.last_reviewed_by', 'r.independent_review', 'r.metadata_version', 'r.source_reference', 'r.essential', 'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.last_reviewed_at', 'r.last_reviewed_by', 'r.independent_review', 'r.metadata_version', 'r.source_reference', 'r.essential',
'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
])->paginate(20)->withQueryString(); ])->paginate(20)->withQueryString();
$pageResult->getCollection()->transform(fn ($resource) => app(PublicDocumentPresentation::class)->resource($resource)); $pageResult->getCollection()->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
$props['resources'] = $pageResult; $props['resources'] = $pageResult;
app(PublicSearchMetrics::class)->candidate($request, 'documents', $pageResult->total(), $filters);
$props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type'); $props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type');
$props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language'); $props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language');
$props['issuers'] = (clone $this->resources())->whereNotNull('d.issuer')->distinct()->orderBy('d.issuer')->pluck('d.issuer'); $props['issuers'] = (clone $this->resources())->whereNotNull('d.issuer')->distinct()->orderBy('d.issuer')->pluck('d.issuer');
@@ -156,7 +144,6 @@ class PortalServicesController extends Controller
$query->where('title', 'ilike', '%'.$filters['q'].'%'); $query->where('title', 'ilike', '%'.$filters['q'].'%');
} }
$props['partnerships'] = $query->orderByDesc('id')->select(['id', 'title', 'sector', 'province', 'offer', 'seeking'])->paginate(20)->withQueryString(); $props['partnerships'] = $query->orderByDesc('id')->select(['id', 'title', 'sector', 'province', 'offer', 'seeking'])->paginate(20)->withQueryString();
app(PublicSearchMetrics::class)->candidate($request, 'partnerships', $props['partnerships']->total(), $filters);
} }
if ($page === 'desk') { if ($page === 'desk') {
if (app(VirtualDesk::class)->installed()) { if (app(VirtualDesk::class)->installed()) {
@@ -201,7 +188,7 @@ class PortalServicesController extends Controller
} }
$resources = $query->orderByDesc('r.essential')->orderBy('r.title')->limit(10) $resources = $query->orderByDesc('r.essential')->orderBy('r.title')->limit(10)
->get(['r.id', 'r.title', 'r.description', 'r.document_type', 'r.document_date', 'r.document_number', 'r.applicable_from', 'r.applicable_until', 'r.language', 'r.territorial_scope', 'r.legal_status', 'r.source_confirmation', 'r.source_url', 'd.issuer', 'd.media_type', 'd.size_bytes']); ->get(['r.id', 'r.title', 'r.description', 'r.document_type', 'r.document_date', 'r.document_number', 'r.applicable_from', 'r.applicable_until', 'r.language', 'r.territorial_scope', 'r.legal_status', 'r.source_confirmation', 'r.source_url', 'd.issuer', 'd.media_type', 'd.size_bytes']);
$resources->transform(fn ($resource) => app(PublicDocumentPresentation::class)->resource($resource)); $resources->transform(fn ($resource) => app(\App\Services\PublicDocumentPresentation::class)->resource($resource));
return response()->json(['data' => $resources]); return response()->json(['data' => $resources]);
} }
@@ -279,7 +266,7 @@ class PortalServicesController extends Controller
abort_unless($resource !== null, 404); abort_unless($resource !== null, 404);
$resource->territory_name = $resource->territory_id $resource->territory_name = $resource->territory_id
? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null; ? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name') : null;
$resource = app(PublicDocumentPresentation::class)->resource($resource); $resource = app(\App\Services\PublicDocumentPresentation::class)->resource($resource);
$back = $request->query('back'); $back = $request->query('back');
$backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//') $backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//')
? $back : '/documentos'; ? $back : '/documentos';
@@ -302,7 +289,7 @@ class PortalServicesController extends Controller
public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response
{ {
$document = app(DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first(); $document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first();
abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean' abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404); && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
@@ -312,7 +299,7 @@ class PortalServicesController extends Controller
public function manage(Request $request, MalwareScanner $scanner): Response public function manage(Request $request, MalwareScanner $scanner): Response
{ {
$managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']); $managementFilters = $request->validate(['q' => 'nullable|string|max:150', 'state' => 'nullable|in:draft,published,withdrawn', 'intent' => 'nullable|in:document,legislation']);
$access = app(DocumentManagementAccess::class); $access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) { if (! $access->full($request->user())) {
$documents = $access->documents($request->user())->where('status', '!=', 'deleted'); $documents = $access->documents($request->user())->where('status', '!=', 'deleted');
$health = $scanner->health(); $health = $scanner->health();
@@ -321,18 +308,14 @@ class PortalServicesController extends Controller
$editorial = $access->editorial($request->user()); $editorial = $access->editorial($request->user());
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at') $resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect(); ->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
if (app(PortalEditorialWorkflow::class)->enabled()) {
$resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r));
}
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id'); $links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id'); $revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(function ($resource) use ($links, $revisions) { $resources->each(function ($resource) use ($links, $revisions) {
$resource->links = $links->get($resource->id, collect())->values(); $resource->links = $links->get($resource->id, collect())->values();
$resource->revisions = $revisions->get($resource->id, collect())->values(); $resource->revisions = $revisions->get($resource->id, collect())->values();
}); });
return Inertia::render('portal/manage', [ return Inertia::render('portal/manage', [
'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [], 'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false, 'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(), 'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
@@ -357,15 +340,12 @@ class PortalServicesController extends Controller
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values()); $resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id'); $revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values()); $resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
if (app(PortalEditorialWorkflow::class)->enabled()) {
$resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r));
}
$malwareHealth = $scanner->health(); $malwareHealth = $scanner->health();
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM); $malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources, return Inertia::render('portal/manage', ['resources' => $resources,
'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'requirementCatalogue' => $this->requirementEvidenceEnabled() 'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values() ? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(), : collect(),
@@ -391,9 +371,6 @@ class PortalServicesController extends Controller
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]); 'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
} }
/**
* @param array<array-key, mixed> $filters
*/
private function filterManagedResources(Builder $query, array $filters): Builder private function filterManagedResources(Builder $query, array $filters): Builder
{ {
if (! empty($filters['q'])) { if (! empty($filters['q'])) {
@@ -407,28 +384,23 @@ class PortalServicesController extends Controller
$query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn'])); $query->$method(fn ($q) => $q->selectRaw('1')->from('portal_resource_revisions as history')->whereColumn('history.resource_id', 'r.id')->whereIn('history.change_type', ['unpublished', 'withdrawn']));
} }
} }
return $query; return $query;
} }
/**
* @return array<array-key, mixed>
*/
private function contentAssociations(Request $request): array private function contentAssociations(Request $request): array
{ {
$access = app(DocumentManagementAccess::class); $access = app(\App\Services\DocumentManagementAccess::class);
$resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at') $resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
: ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null); : ($access->editorial($request->user()) ? $access->editorialResources($request->user()) : null);
$documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect(); $documents = $resources ? $resources->orderBy('r.title')->get(['r.id as value', 'r.title as label', 'r.document_type']) : collect();
return [ return [
'sector' => Sector::choices()->orderBy('name')->get(['id as value', 'name as label']), 'sector' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
'territory' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']), 'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
'opportunity' => app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']), 'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
'document' => $documents, 'document' => $documents,
'procedure' => $documents->where('document_type', 'procedure')->values(), 'procedure' => $documents->where('document_type', 'procedure')->values(),
'stage' => collect(Config::array('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(), 'stage' => collect(config('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
'activity' => app(EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(), 'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
]; ];
} }
@@ -473,7 +445,7 @@ class PortalServicesController extends Controller
$id = DB::transaction(function () use ($data, $request): int { $id = DB::transaction(function () use ($data, $request): int {
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first(); $document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.'); abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403); abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data); $identity = $this->identityKey($data);
if ($identity) { if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]); DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
@@ -481,15 +453,9 @@ class PortalServicesController extends Controller
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null; $duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true); $fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
$this->assertPublicClassificationEligible($data, $document); $this->assertPublicClassificationEligible($data, $document);
$workflow = app(PortalEditorialWorkflow::class);
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]); $id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
$this->syncResourceLinks($id, $data['associations'] ?? []); $this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'created', $request->user()->id, 1); $this->recordResourceRevision($id, 'created', $request->user()->id, 1);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, 1, ['_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]]);
return $id;
}
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]); 'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
@@ -508,13 +474,11 @@ class PortalServicesController extends Controller
DB::transaction(function () use ($data, $request, $id, $expectedVersion): void { DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); $resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404); abort_unless($resource !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$workflow = app(PortalEditorialWorkflow::class); abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$current = $workflow->enabled() ? $workflow->workingResource($resource) : $resource;
abort_if((int) $current->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first(); $document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.'); abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403); abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data); $identity = $this->identityKey($data);
if ($identity) { if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]); DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
@@ -522,12 +486,6 @@ class PortalServicesController extends Controller
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null; $duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false); $fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
$this->assertPublicClassificationEligible($data, $document); $this->assertPublicClassificationEligible($data, $document);
$workflow = app(PortalEditorialWorkflow::class);
if ($workflow->enabled()) {
$workflow->save($request->user(), $id, $expectedVersion, [...$fields, '_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]], $data['associations'] ?? []);
return;
}
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized' $retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url']) && $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed') && (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
@@ -544,12 +502,12 @@ class PortalServicesController extends Controller
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id); $this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
}); });
return to_route('services.manage')->with('success', 'Edição guardada; confira o circuito editorial quando estiver activo. A versão pública é preservada nesse circuito.'); return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
} }
public function moderate(Request $request, string $type, int $id): RedirectResponse public function moderate(Request $request, string $type, int $id): RedirectResponse
{ {
$access = app(DocumentManagementAccess::class); $access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) { if (! $access->full($request->user())) {
abort_unless($type === 'resources', 403); abort_unless($type === 'resources', 403);
$access->assertEditableResource($request->user(), $id); $access->assertEditableResource($request->user(), $id);
@@ -558,18 +516,10 @@ class PortalServicesController extends Controller
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.'); return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
} }
$data = $this->validateManagement($request, ['published' => ['required', 'boolean']]); $data = $this->validateManagement($request, ['published' => ['required', 'boolean']]);
if ($type === 'resources' && app(PortalEditorialWorkflow::class)->enabled()) {
app(PortalEditorialWorkflow::class)->authorize($request->user(), $id, true);
}
if ($type === 'resources' && $data['published'] && app(PortalEditorialWorkflow::class)->enabled()) {
abort(409, 'Use a aprovação e publicação explícitas da edição no circuito editorial.');
}
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships'; $table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) { DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404); abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
if ($type === 'resources') { if ($type === 'resources') app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
}
if ($type === 'resources' && $data['published']) { if ($type === 'resources' && $data['published']) {
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') $resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status', ->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status',
@@ -609,7 +559,6 @@ class PortalServicesController extends Controller
public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse
{ {
abort_if(app(PortalEditorialWorkflow::class)->enabled(), 409, 'A divulgação de novas edições requer aprovação e publicação no circuito editorial.');
$event = DB::table('audit_events')->where('source_system', 'user-instruction') $event = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1') ->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first(); ->where('action', 'public_disclosure_authorized')->first();
@@ -883,6 +832,17 @@ class PortalServicesController extends Controller
&& DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists(); && DB::table('migrations')->where('migration', '2026_10_06_200000_add_document_requirement_evidence_links')->exists();
} }
/** @return list<string> */
private function resourceLinkColumns(): array
{
$columns = ['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference'];
if (Schema::hasColumn('portal_resource_links', 'review_status')) {
$columns[] = 'review_status';
}
return $columns;
}
/** @return list<string> */ /** @return list<string> */
private function territoryScopeIds(string $id): array private function territoryScopeIds(string $id): array
{ {
@@ -906,23 +866,17 @@ SQL, [$id]);
return $ids ?: [$id]; return $ids ?: [$id];
} }
private function articleUrl(int $id): ?string private function articleUrl(int $id): ?string
{ {
if (! Schema::hasColumn('portal_resources', 'editorial_content')) { if (!Schema::hasColumn('portal_resources', 'editorial_content')) return null;
return null; $resource = DB::table('portal_resources')->find($id);
} if (!$resource) return null;
$resource = DB::table('portal_resources')->where('id', $id)->first(); if ($resource->editorial_content !== null) return '/artigos/'.$id;
if (! $resource) {
return null;
}
if ($resource->editorial_content !== null) {
return '/artigos/'.$id;
}
try { try {
return app(EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null; return app(\App\Services\EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null;
} catch (HttpException) { } catch (\Symfony\Component\HttpKernel\Exception\HttpException) {
return null; return null;
} }
} }
} }
@@ -1,51 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\PrivateOfferOperations;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
final class PrivateOfferOperationsController extends Controller
{
public function show(Request $request, int $id, PrivateOfferOperations $operations): Response
{
return response()->json($operations->presentation($request->user(), $id));
}
public function owner(Request $request, int $id, PrivateOfferOperations $operations): Response
{
$operations->ownerAction($request->user(), $id, $request->all());
return response()->json($operations->presentation($request->user(), $id));
}
public function reportPage(Request $request, int $id, PrivateOfferOperations $operations): Response
{
$record = Opportunity::findOrFail($id);
abort_unless($record->isPrivateOfferPreparation() && $operations->canReport($request->user(), $record), 404);
return Inertia::render('offers/report', ['offerId' => $id])->toResponse($request);
}
public function report(Request $request, int $id, PrivateOfferOperations $operations): Response
{
return response()->json($operations->report($request->user(), $id, $request->all()), 201);
}
public function handleReport(Request $request, int $id, string $reportId, PrivateOfferOperations $operations): Response
{
$operations->handleReport($request->user(), $id, $reportId, $request->all());
return response()->json($operations->presentation($request->user(), $id));
}
public function highlight(Request $request, int $id, PrivateOfferOperations $operations): Response
{
$operations->highlight($request->user(), $id, $request->all());
return response()->json($operations->presentation($request->user(), $id));
}
}
@@ -1,153 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OfferAvailability;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityResources;
use App\Services\PortalProfileAccess;
use App\Services\PrivateOfferConfidentiality;
use App\Services\PrivateOfferOperations;
use App\Services\PrivateOfferPreparation;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
final class PrivateOfferPreparationController extends Controller
{
public function resources(Request $request, PrivateOfferPreparation $service, int $id): JsonResponse
{
$record = $service->record($request->user(), $id);
$resources = [];
foreach (['images', 'documents'] as $kind) {
$resources[$kind] = array_map(fn ($row) => ['id' => $row->id, 'version' => $row->version, 'deleted' => $row->deleted_at !== null,
'caption' => $row->payload['caption'] ?? null, 'digitally_edited' => $row->payload['digitally_edited'] ?? null,
'edit_description' => $row->payload['edit_description'] ?? null,
'scan' => $row->file->malware_scan_status ?? 'pending'], app(OpportunityResources::class)->listing($id, $kind));
}
return response()->json(['resources' => $resources, 'etag' => app(OpportunityManagement::class)->etag($record),
'internalProofAccess' => app(PrivateOfferConfidentiality::class)->internal($request->user(), $record)]);
}
public function attachment(Request $request, PrivateOfferPreparation $service, int $id, string $kind, string $resourceId): \Symfony\Component\HttpFoundation\Response
{
$record = $service->record($request->user(), $id);
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$link = DB::table('opportunity_'.$kind)->where('opportunity_id', $id)->where('id', $resourceId)->whereNull('deleted_at')->first();
abort_unless($link !== null, 404);
$document = DB::table('documents')->where('id', $link->document_id)->whereNull('deleted_at')->first();
abort_unless($document !== null, 404);
return app(VerifiedDocumentBinary::class)->respond($document);
}
public function confidentiality(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse|Response
{
$overview = $privacy->overview($request->user(), $id);
return $request->expectsJson() ? response()->json($overview) : Inertia::render('offers/confidentiality', ['offerId' => $id]);
}
public function confidentialityAction(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse
{
return response()->json($privacy->act($request->user(), $id, $request->all()));
}
public function confidentialRead(Request $request, PrivateOfferConfidentiality $privacy, int $id): JsonResponse
{
try {
$result = $privacy->read($request->user(), $id);
} catch (HttpExceptionInterface $error) {
app(PortalProfileAccess::class)->audit($request->user(), 'offer_confidential_denied', ['record_id' => $id, 'http_status' => $error->getStatusCode()]);
throw $error;
}
abort_if(isset($result['denied']), 403, 'É necessária aceitação do NDA e autorização expressa do vendedor para esta versão.');
return response()->json($result);
}
public function nda(Request $request, PrivateOfferConfidentiality $privacy, int $id): \Symfony\Component\HttpFoundation\Response
{
$overview = $privacy->overview($request->user(), $id);
abort_unless($overview['nda'], 409, 'O NDA aprovado ainda não foi disponibilizado.');
app(PortalProfileAccess::class)->audit($request->user(), 'offer_nda_access', ['record_id' => $id, 'version_hash' => $overview['version_hash'], 'nda' => $overview['nda']]);
$document = DB::table('documents')->where('id', $overview['nda']['document_id'])->firstOrFail();
abort_unless((int) $document->version_no === $overview['nda']['version'] && hash_equals(trim($document->sha256), $overview['nda']['sha256']), 409, 'O exemplar do NDA mudou; actualize antes de continuar.');
return app(VerifiedDocumentBinary::class)->respond($document);
}
public function index(Request $request, PortalProfileAccess $access): Response
{
$filters = $request->validate(['state' => 'nullable|in:draft,submitted,in_review,needs_information,rejected,approved', 'operational' => 'nullable|boolean', 'pending_reports' => 'nullable|boolean']);
$query = ($filters['operational'] ?? false) ? app(PrivateOfferOperations::class)->staffQuery($request->user()) : $access->scope(Opportunity::query(), $request->user());
if ($filters['pending_reports'] ?? false) {
$query = app(PrivateOfferOperations::class)->staffQuery($request->user());
$query->whereRaw("jsonb_path_exists(COALESCE(workflow_context->'offer_reports','{}'::jsonb), '$.* ? (@.status == \"open\" || @.status == \"in_review\")')");
}
$records = $query->whereNotNull('workflow_context->private_offer_preparation')
->when($filters['state'] ?? null, fn ($q, $state) => $q->where('workflow_context->private_offer_preparation->state', $state))
->orderByDesc('id')->paginate(20)->withQueryString();
return Inertia::render('offers/preparation-index', ['records' => $records->through(fn ($r) => [
'id' => $r->id, 'title' => app(OpportunityManagement::class)->values($r)['title'] ?? null, 'reference' => $r->reference,
'state' => $access->state($r),
'availability' => app(OfferAvailability::class)->record($r)['state'],
]), 'filters' => $filters, 'canCreate' => count($access->navigation($request->user())['contexts']) > 0]);
}
public function edit(Request $request, PrivateOfferPreparation $service, ?int $id = null): Response|JsonResponse
{
$record = $id ? $service->record($request->user(), $id) : null;
$contexts = app(PortalProfileAccess::class)->navigation($request->user())['contexts'];
abort_unless($record || count($contexts) > 0, 403, 'É necessária uma atribuição de promotor no âmbito da oferta.');
$presentation = $record ? $service->presentation($request->user(), $record) : null;
if ($request->expectsJson() && ! $request->header('X-Inertia')) {
return response()->json(['record' => $presentation]);
}
$options = app(OpportunityCanonicalContract::class)->options($request->user(), $record);
return Inertia::render('offers/preparation', ['record' => $presentation, 'contexts' => $contexts,
'contacts' => $options['contacts'], 'contactInvestors' => $options['contactInvestors'],
'sectors' => DB::table('sectors')->where('is_active', true)->orderBy('name')->get(['id', 'name']),
'propertyTypes' => array_values(array_filter($options['definitions']['realEstatePatch']['properties']['property_type']['enum'], fn ($type) => is_string($type)))]);
}
public function save(Request $request, PrivateOfferPreparation $service, ?int $id = null): JsonResponse
{
if ($id) {
$service->record($request->user(), $id, true);
}
$record = $service->save($request->user(), $request->all(), $id);
return response()->json(['record' => $service->presentation($request->user(), $record)], $id ? 200 : 201);
}
public function submit(Request $request, PrivateOfferPreparation $service, int $id): JsonResponse
{
$record = $service->submit($request->user(), $id, $request->all());
return response()->json(['record' => $service->presentation($request->user(), $record),
'message' => 'Submissão privada registada. A oferta não foi publicada.']);
}
public function transition(Request $request, PrivateOfferPreparation $service, OpportunityProfileWorkflow $workflow, int $id): JsonResponse
{
$service->record($request->user(), $id);
$data = $request->validate(['etag' => 'required|string|size:64', 'action' => 'required|in:start_review,assign_review,request_correction,reject,approve,request_revision',
'assignee_user_id' => 'required_if:action,assign_review|integer|min:1',
'reason' => 'required|string|min:20|max:1000', 'correction_items' => 'sometimes|array', 'correction_items.*' => 'string']);
$record = $workflow->transition($request->user(), $id, $data);
return response()->json(['record' => $service->presentation($request->user(), $record), 'message' => 'Decisão privada registada. A oferta não foi publicada.']);
}
}
@@ -1,34 +1,17 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Services\ProactiveSupply; use App\Services\ProactiveSupply;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Illuminate\Http\JsonResponse;
use Inertia\Response;
final class ProactiveSupplyController extends Controller final class ProactiveSupplyController extends Controller
{ {
public function review(Request $request, ProactiveSupply $service): JsonResponse|Response public function index(Request $request,ProactiveSupply $service): JsonResponse
{ {
$service->authorize($request->user()); $v=$request->validate(['record_id'=>'nullable|integer|min:1']);
$v = $request->validate(['record_id' => 'nullable|integer|min:1']); $record=isset($v['record_id'])?Opportunity::findOrFail($v['record_id']):null;
$record = isset($v['record_id']) ? Opportunity::whereKey($v['record_id'])->firstOrFail() : null; return response()->json($service->search($request->user(),$request->all(),$record))->header('Cache-Control','private, no-store');
$data = $service->review($request->user(), $request->all(), $record);
$data['recordId'] = isset($v['record_id']) ? (int) $v['record_id'] : null;
return $request->expectsJson() ? response()->json($data)->header('Cache-Control', 'private, no-store')
: Inertia::render('management/proactive-supply-review', $data);
}
public function index(Request $request, ProactiveSupply $service): JsonResponse
{
$service->authorize($request->user());
$v = $request->validate(['record_id' => 'nullable|integer|min:1']);
$record = isset($v['record_id']) ? Opportunity::whereKey($v['record_id'])->firstOrFail() : null;
return response()->json($service->search($request->user(), $request->all(), $record))->header('Cache-Control', 'private, no-store');
} }
} }
+43 -29
View File
@@ -3,26 +3,43 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\Procurement; use App\Services\Procurement;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class ProcurementController extends Controller class ProcurementController extends Controller
{ {
private function query(Procurement $service): Builder private const STATE_SQL = "CASE WHEN v.payload->>'procedural_state' IN ('cancelled','results') THEN v.payload->>'procedural_state' WHEN ((v.payload->>'deadline')::timestamp AT TIME ZONE (v.payload->>'timezone'))<=CURRENT_TIMESTAMP THEN 'deadline_ended' WHEN v.payload->>'extended'='true' THEN 'extended' ELSE 'open' END";
private function query(Procurement $service)
{ {
return $service->publicQuery(); abort_unless($service->available(), 404);
return DB::table('portal_tenders as t')->join('portal_tender_versions as v', fn ($q) => $q->on('v.id', '=', 't.published_version_id')->on('v.tender_id', '=', 't.id'))->whereNotNull('v.published_at');
} }
public function index(Request $request, Procurement $service): Response public function index(Request $request, Procurement $service)
{ {
$filters = $request->validate(['q' => 'nullable|string|max:150', 'entity' => 'nullable|uuid', 'sector' => 'nullable|integer', 'territory' => 'nullable|uuid', 'modality' => 'nullable|string|max:200', 'financier' => 'nullable|string|max:200', 'state' => 'nullable|in:open,extended,deadline_ended,cancelled,results', 'deadline_before' => 'nullable|date_format:Y-m-d', 'regime' => 'nullable|in:state,external,private,other']); $filters = $request->validate(['q' => 'nullable|string|max:150', 'entity' => 'nullable|uuid', 'sector' => 'nullable|integer', 'territory' => 'nullable|uuid', 'modality' => 'nullable|string|max:200', 'financier' => 'nullable|string|max:200', 'state' => 'nullable|in:open,extended,deadline_ended,cancelled,results', 'deadline_before' => 'nullable|date_format:Y-m-d', 'regime' => 'nullable|in:state,external,private,other']);
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== ''); $filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
$query = $service->publicQuery($filters); $query = $this->query($service);
if (! empty($filters['q'])) {
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'object' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'reference' ILIKE ?", ['%'.$filters['q'].'%']));
}
foreach (['entity' => 'organization_id', 'sector' => 'sector_id', 'modality' => 'modality', 'financier' => 'financier', 'regime' => 'regime'] as $filter => $field) {
if (! empty($filters[$filter])) {
$query->whereRaw('v.payload->>? = ?', [$field, (string) $filters[$filter]]);
}
}
if (! empty($filters['territory'])) {
$query->whereRaw("v.payload->>'territory_id' IN (WITH RECURSIVE scope(id,depth) AS (SELECT id,0 FROM geo_locations WHERE id=? AND deleted_at IS NULL UNION ALL SELECT c.id,s.depth+1 FROM geo_locations c JOIN scope s ON c.parent_id=s.id WHERE c.deleted_at IS NULL AND s.depth<4) SELECT id::text FROM scope)", [$filters['territory']]);
}
if (! empty($filters['state'])) {
$query->whereRaw(self::STATE_SQL.' = ?', [$filters['state']]);
}
if (! empty($filters['deadline_before'])) {
$query->whereRaw("left(v.payload->>'deadline',10)<=?", [$filters['deadline_before']]);
}
$rows = $query->select('t.id', 'v.id as vid')->orderByDesc('v.published_at')->paginate(20)->withQueryString(); $rows = $query->select('t.id', 'v.id as vid')->orderByDesc('v.published_at')->paginate(20)->withQueryString();
$rows->through(fn ($r) => ['id' => $r->id, ...$this->summary($service, $r->vid)]); $rows->through(fn ($r) => ['id' => $r->id, ...$this->summary($service, $r->vid)]);
$facets = $this->query($service)->get(['v.payload'])->map(fn ($r) => json_decode($r->payload, true)); $facets = $this->query($service)->get(['v.payload'])->map(fn ($r) => json_decode($r->payload, true));
@@ -31,12 +48,9 @@ class ProcurementController extends Controller
$options[$name] = $facets->filter(fn ($p) => ! empty($p[$value]))->map(fn ($p) => ['id' => (string) $p[$value], 'name' => $p[$label]])->unique('id')->values(); $options[$name] = $facets->filter(fn ($p) => ! empty($p[$value]))->map(fn ($p) => ['id' => (string) $p[$value], 'name' => $p[$label]])->unique('id')->values();
} }
return tap(Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); return Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request)->header('Cache-Control', 'private, no-store');
} }
/**
* @return array<array-key, mixed>
*/
private function summary(Procurement $service, string $vid): array private function summary(Procurement $service, string $vid): array
{ {
$v = $service->publishedVersion($vid); $v = $service->publishedVersion($vid);
@@ -44,62 +58,62 @@ class ProcurementController extends Controller
return ['publication' => $v, 'state' => $service->state($v)]; return ['publication' => $v, 'state' => $service->state($v)];
} }
public function show(Request $request, string $id, Procurement $service): Response public function show(Request $request, string $id, Procurement $service)
{ {
return $this->detail($request, $id, $service, false); return $this->detail($request, $id, $service, false);
} }
public function print(Request $request, string $id, Procurement $service): Response public function print(Request $request, string $id, Procurement $service)
{ {
return $this->detail($request, $id, $service, true); return $this->detail($request, $id, $service, true);
} }
private function detail(Request $request, string $id, Procurement $service, bool $print): Response private function detail(Request $request, string $id, Procurement $service, bool $print)
{ {
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['t.id', 'v.id as vid']); $row = $this->query($service)->where('t.id', $id)->firstOrFail(['t.id', 'v.id as vid']);
$filters = $request->only(['q', 'entity', 'sector', 'territory', 'modality', 'financier', 'state', 'deadline_before', 'regime', 'page']); $filters = $request->only(['q', 'entity', 'sector', 'territory', 'modality', 'financier', 'state', 'deadline_before', 'regime', 'page']);
return tap(Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''), return Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''),
'history' => DB::table('portal_tender_versions')->where('tender_id', $id)->whereNotNull('published_at')->orderBy('version')->pluck('id')->map(fn ($vid) => $service->publishedVersion($vid)), 'history' => DB::table('portal_tender_versions')->where('tender_id', $id)->whereNotNull('published_at')->orderBy('version')->pluck('id')->map(fn ($vid) => $service->publishedVersion($vid)),
'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); 'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request)->header('Cache-Control', 'private, no-store');
} }
public function manage(Request $request, Procurement $service): Response public function manage(Request $request, Procurement $service)
{ {
$service->guard($request->user()); $service->guard($request->user());
return tap(Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); return Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request)->header('Cache-Control', 'private, no-store');
} }
public function edit(Request $request, Procurement $service, ?string $id = null): Response public function edit(Request $request, Procurement $service, ?string $id = null)
{ {
$service->guard($request->user()); $service->guard($request->user());
$r = $id ? $service->record($id) : null; $r = $id ? $service->record($id) : null;
return tap(Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null, return Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null,
'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); 'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request)->header('Cache-Control', 'private, no-store');
} }
public function save(Request $request, Procurement $service, ?string $id = null): RedirectResponse public function save(Request $request, Procurement $service, ?string $id = null)
{ {
$id = $service->save($request->user(), $id, $request->all()); $id = $service->save($request->user(), $id, $request->all());
return redirect('/management/concursos/'.$id)->with('success', 'Rascunho guardado. A publicação anterior permanece intacta.'); return redirect('/management/concursos/'.$id)->with('success', 'Rascunho guardado. A publicação anterior permanece intacta.');
} }
public function preview(Request $request, string $id, Procurement $service): JsonResponse public function preview(Request $request, string $id, Procurement $service)
{ {
return response()->json($service->preview($request->user(), $id, (string) $request->input('act')))->header('Cache-Control', 'private, no-store'); return response()->json($service->preview($request->user(), $id, (string) $request->input('act')))->header('Cache-Control', 'private, no-store');
} }
public function publish(Request $request, string $id, Procurement $service): RedirectResponse public function publish(Request $request, string $id, Procurement $service)
{ {
$service->publish($request->user(), $id, $request->all()); $service->publish($request->user(), $id, $request->all());
return redirect('/management/concursos/'.$id)->with('success', 'Acto publicado, com versão e auditoria. Nenhuma proposta recebida pelo portal.'); return redirect('/management/concursos/'.$id)->with('success', 'Acto publicado, com versão e auditoria. Nenhuma proposta recebida pelo portal.');
} }
public function watch(Request $request, string $id, Procurement $service): RedirectResponse public function watch(Request $request, string $id, Procurement $service)
{ {
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['v.id']); $row = $this->query($service)->where('t.id', $id)->firstOrFail(['v.id']);
DB::table('portal_tender_watches')->updateOrInsert(['tender_id' => $id, 'user_id' => $request->user()->id], ['seen_version_id' => $row->id, 'created_at' => now(), 'updated_at' => now()]); DB::table('portal_tender_watches')->updateOrInsert(['tender_id' => $id, 'user_id' => $request->user()->id], ['seen_version_id' => $row->id, 'created_at' => now(), 'updated_at' => now()]);
@@ -107,7 +121,7 @@ class ProcurementController extends Controller
return back()->with('success', 'Concurso guardado para acompanhamento. Isto não apresenta uma proposta.'); return back()->with('success', 'Concurso guardado para acompanhamento. Isto não apresenta uma proposta.');
} }
public function unwatch(Request $request, string $id, Procurement $service): RedirectResponse public function unwatch(Request $request, string $id, Procurement $service)
{ {
abort_unless($service->available(), 404); abort_unless($service->available(), 404);
DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete(); DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete();
@@ -115,10 +129,10 @@ class ProcurementController extends Controller
return back(); return back();
} }
public function watched(Request $request, Procurement $service): Response public function watched(Request $request, Procurement $service)
{ {
$rows = $this->query($service)->join('portal_tender_watches as w', 'w.tender_id', '=', 't.id')->where('w.user_id', $request->user()->id)->get(['t.id', 'v.id as vid', 'w.seen_version_id'])->map(fn ($r) => ['id' => $r->id, 'changed' => $r->seen_version_id !== $r->vid, ...$this->summary($service, $r->vid)]); $rows = $this->query($service)->join('portal_tender_watches as w', 'w.tender_id', '=', 't.id')->where('w.user_id', $request->user()->id)->get(['t.id', 'v.id as vid', 'w.seen_version_id'])->map(fn ($r) => ['id' => $r->id, 'changed' => $r->seen_version_id !== $r->vid, ...$this->summary($service, $r->vid)]);
return tap(Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); return Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request)->header('Cache-Control', 'private, no-store');
} }
} }
@@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\ProductExperiments;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ProductExperimentController extends Controller
{
public function index(Request $request, ProductExperiments $experiments): Response|JsonResponse
{
$data = $experiments->listing($request->user(), $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/experiments/index', $data);
}
public function create(Request $request, ProductExperiments $experiments): Response|JsonResponse
{
return $this->show($request, $experiments);
}
public function show(Request $request, ProductExperiments $experiments, ?string $id = null): Response|JsonResponse
{
$data = $experiments->view($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/experiments/edit', $data);
}
public function store(Request $request, ProductExperiments $experiments): JsonResponse
{
return response()->json($experiments->save($request->user(), null, $request->all()), 201);
}
public function revise(Request $request, ProductExperiments $experiments, string $id): JsonResponse
{
return response()->json($experiments->save($request->user(), $id, $request->all()));
}
}
@@ -1,19 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\ProductUsageMetrics;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ProductUsageMetricsController extends Controller
{
public function index(Request $request, ProductUsageMetrics $metrics): Response|JsonResponse
{
$data = ['report' => $metrics->report($request->user(), $request->all())];
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/product-usage', $data);
}
}
@@ -4,16 +4,11 @@ namespace App\Http\Controllers;
use App\Http\Resources\PublicBusinessOpportunityResource; use App\Http\Resources\PublicBusinessOpportunityResource;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Services\BusinessProximitySearch;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessClassification;
use App\Services\ExistingBusinessDrafts; use App\Services\ExistingBusinessDrafts;
use App\Services\InvestmentGeography; use App\Services\ExistingBusinessClassification;
use App\Services\OpportunityClassification; use App\Services\OpportunityClassification;
use App\Services\PublicBusinessLocations; use App\Services\InvestmentGeography;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -33,7 +28,13 @@ class PublicBusinessOpportunityController extends Controller
$path = app(InvestmentGeography::class)->selection($filters); $path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) { if ($path !== []) {
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id); $territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->whereExists(app(PublicBusinessLocations::class)->matchingArea($territoryIds)->selectRaw('1')->whereColumn('l.existing_business_id', 'existing_businesses.id')); $query->whereExists(fn ($locations) => $locations->selectRaw('1')->from('existing_business_locations as matching_location')
->whereColumn('matching_location.existing_business_id', 'existing_businesses.id')->where('matching_location.is_public', true)
->where(function ($match) use ($territoryIds): void {
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id'] as $field) {
$match->orWhereIn('matching_location.'.$field, $territoryIds);
}
}));
} }
if (! empty($filters['q'])) { if (! empty($filters['q'])) {
$term = trim($filters['q']); $term = trim($filters['q']);
@@ -42,35 +43,23 @@ class PublicBusinessOpportunityController extends Controller
->orWhereLike('existing_businesses.objective', '%'.$term.'%')); ->orWhereLike('existing_businesses.objective', '%'.$term.'%'));
} }
if (! empty($filters['sector'])) { if (! empty($filters['sector'])) {
$sector = Sector::whereKey($filters['sector'])->first(); $sector = \App\Models\Sector::find($filters['sector']);
$query->whereIn('existing_businesses.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []); $query->whereIn('existing_businesses.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} }
if (! empty($filters['modality'])) { if (! empty($filters['modality'])) {
$query->where('existing_businesses.modality', $filters['modality']); $query->where('existing_businesses.modality', $filters['modality']);
} }
$coverage = null; $results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->appends($filters));
if (! empty($filters['near_business'])) {
$proximity = app(BusinessProximitySearch::class);
$coverage = $proximity->constrain($query, $proximity->origin($filters['near_business']), (float) $filters['radius_km']);
} else {
$query->orderByDesc('existing_businesses.published_at')->orderBy('existing_businesses.id');
}
$page = $query->paginate($filters['per_page'] ?? 12)->appends($filters);
app(PublicSearchMetrics::class)->candidate($request, 'businesses', $page->total(), $filters);
$results = PublicBusinessOpportunityResource::collection($page)->additional(['geographicCoverage' => $coverage]);
if ($request->expectsJson()) { if ($request->expectsJson()) {
return $results->response(); return $results->response();
} }
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters, 'territorySelection' => $path, 'geographicCoverage' => $coverage, return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters, 'territorySelection' => $path,
'classificationOptions' => app(OpportunityClassification::class)->options(), 'classificationOptions' => app(OpportunityClassification::class)->options(),
'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz'])]); 'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz'])]);
} }
/**
* @return array<array-key, mixed>
*/
private function filters(Request $request): array private function filters(Request $request): array
{ {
return $request->validate([ return $request->validate([
@@ -78,7 +67,6 @@ class PublicBusinessOpportunityController extends Controller
'modality' => ['nullable', Rule::in(ExistingBusinessDrafts::MODALITIES)], 'modality' => ['nullable', Rule::in(ExistingBusinessDrafts::MODALITIES)],
'sector' => ['nullable', 'integer', 'min:1'], 'sector' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'near_business' => ['nullable', 'required_with:radius_km', 'uuid'], 'radius_km' => ['nullable', 'required_with:near_business', 'numeric', 'min:0.1', 'max:2000'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'],
'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
...app(ExistingBusinessClassification::class)->filterRules(), ...app(ExistingBusinessClassification::class)->filterRules(),
@@ -94,18 +82,8 @@ class PublicBusinessOpportunityController extends Controller
} }
$returnFilters = $this->filters($request); $returnFilters = $this->filters($request);
$similarProvince = $record->getAttribute('similar_province_id');
if ($similarProvince !== null && ! app(InvestmentGeography::class)->publicReferences()->where('geo_locations.id', $similarProvince)->exists()) {
$similarProvince = null;
}
return Inertia::render('investment/business-opportunity', ['record' => $resource->resolve($request), return Inertia::render('investment/business-opportunity', ['record' => $resource->resolve($request),
'similarRadiusAvailable' => app(BusinessProximitySearch::class)->available($record->id),
'similarCatalogueUrl' => '/negocios?'.http_build_query(array_filter([
'modality' => $record->modality,
'sector' => $record->sector_id,
'province_id' => $similarProvince,
], fn ($value) => $value !== null && $value !== '')),
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters), 'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]); 'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]);
} }
@@ -127,14 +105,14 @@ class PublicBusinessOpportunityController extends Controller
/** @return Builder<ExistingBusiness> */ /** @return Builder<ExistingBusiness> */
private function publishedQuery(): Builder private function publishedQuery(): Builder
{ {
$publicLocations = app(PublicBusinessLocations::class)->primary() $publicLocations = DB::table('existing_business_locations as locations')
->leftJoin('geo_locations as province', 'province.id', '=', 'l.province_id') ->leftJoin('geo_locations as province', 'province.id', '=', 'locations.province_id')
->leftJoin('geo_locations as district', 'district.id', '=', 'l.district_id') ->leftJoin('geo_locations as district', 'district.id', '=', 'locations.district_id')
->leftJoin('geo_locations as locality', 'locality.id', '=', 'l.locality_id') ->leftJoin('geo_locations as locality', 'locality.id', '=', 'locations.locality_id')
->where('locations.is_public', true)->where('locations.is_primary', true)
->whereNull('province.deleted_at')->whereNull('district.deleted_at')->whereNull('locality.deleted_at') ->whereNull('province.deleted_at')->whereNull('district.deleted_at')->whereNull('locality.deleted_at')
->select('l.existing_business_id', 'l.updated_at as location_recorded_at', 'province.name as province', 'province.id as similar_province_id') ->select('locations.existing_business_id', 'province.name as province')
->selectRaw(app(PublicBusinessLocations::class)->sourceProjection()) ->selectRaw("CASE WHEN locations.precision_level IN ('district','admin_post','locality','exact') THEN district.name ELSE NULL END AS district")
->selectRaw("CASE WHEN l.precision_level IN ('district','admin_post','locality','exact') THEN district.name ELSE NULL END AS district")
->selectRaw('NULL::text AS locality'); ->selectRaw('NULL::text AS locality');
return ExistingBusiness::query()->published()->with('sector:id,name,slug') return ExistingBusiness::query()->published()->with('sector:id,name,slug')
@@ -144,7 +122,6 @@ class PublicBusinessOpportunityController extends Controller
'existing_businesses.currency', 'existing_businesses.amount_min', 'existing_businesses.amount_max', 'existing_businesses.currency', 'existing_businesses.amount_min', 'existing_businesses.amount_max',
'existing_businesses.available_percentage', 'existing_businesses.published_at', 'existing_businesses.sector_id', 'existing_businesses.available_percentage', 'existing_businesses.published_at', 'existing_businesses.sector_id',
'existing_businesses.owner_user_id', 'existing_businesses.organization_id', 'existing_businesses.version_no', 'existing_businesses.expires_at', 'existing_businesses.status', 'existing_businesses.modality', 'existing_businesses.owner_user_id', 'existing_businesses.organization_id', 'existing_businesses.version_no', 'existing_businesses.expires_at', 'existing_businesses.status', 'existing_businesses.modality',
'public_location.province', 'public_location.district', 'public_location.locality', 'public_location.similar_province_id', 'public_location.province', 'public_location.district', 'public_location.locality']);
'public_location.location_recorded_at', 'public_location.location_source_reference', 'public_location.location_source_date']);
} }
} }
@@ -2,26 +2,21 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class PublicGeographyController extends Controller class PublicGeographyController extends Controller
{ {
public function options(Request $request, InvestmentGeography $geography): JsonResponse public function options(Request $request, InvestmentGeography $geography): JsonResponse
{ {
$data = $request->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]); $data = $request->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = $geography->publicReferences()->where('location_type', $data['type']); $query = $geography->publicReferences()->where('location_type', $data['type']);
if (! empty($data['corridor'])) { if (!empty($data['corridor'])) {
$corridors = app(InvestmentCorridorContext::class); $corridors = app(\App\Services\InvestmentCorridorContext::class);
if ($data['type'] === 'province') { if ($data['type'] === 'province') $query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id'));
$query->whereIn('id', array_column($corridors->provinces($data['corridor']), 'id')); elseif (!$corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) abort(422, 'O território não pertence ao corredor escolhido.');
} elseif (! $corridors->compatible($data['corridor'], $data['parent_id'] ?? null)) {
abort(422, 'O território não pertence ao corredor escolhido.');
}
} }
if (! empty($data['id'])) { if (! empty($data['id'])) {
$query->where('id', $data['id']); $query->where('id', $data['id']);
@@ -6,8 +6,6 @@ use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\SectorInvestment; use App\Services\SectorInvestment;
use App\Services\SectorOrganization;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -21,14 +19,10 @@ class PublicPortalController extends Controller
return Inertia::render('PortalInvestidor', ['initialView' => 'opportunities', 'initialQuery' => $filters['q'] ?? '', 'initialSector' => $filters['sector'] ?? '']); return Inertia::render('PortalInvestidor', ['initialView' => 'opportunities', 'initialQuery' => $filters['q'] ?? '', 'initialSector' => $filters['sector'] ?? '']);
} }
public function sector(string $slug): Response|RedirectResponse public function sector(string $slug): Response|\Illuminate\Http\RedirectResponse
{ {
if (in_array($slug, ['energia', 'fintech'], true)) { if (in_array($slug, ['energia', 'fintech'], true)) return redirect(\App\Services\SectorOrganization::href($slug), 301);
return redirect(SectorOrganization::href($slug), 301); if ($slug === 'mercado-financeiro/fintech') $slug = 'fintech';
}
if ($slug === 'mercado-financeiro/fintech') {
$slug = 'fintech';
}
$sector = Sector::where('slug', $slug)->where('is_active', true)->whereNotNull('published_at')->firstOrFail(); $sector = Sector::where('slug', $slug)->where('is_active', true)->whereNotNull('published_at')->firstOrFail();
return Inertia::render('portal/services', ['section' => 'sector', ...app(SectorInvestment::class)->page($sector)]); return Inertia::render('portal/services', ['section' => 'sector', ...app(SectorInvestment::class)->page($sector)]);
@@ -1,67 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Support\PublicSitemapXml;
use Generator;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PublicSitemapController extends Controller
{
private const PAGE_SIZE = 1000;
public function index(PublicSitemapXml $xml): StreamedResponse
{
$base = $this->base();
$pages = (int) ceil(Opportunity::published()->count() / self::PAGE_SIZE);
$locations = (static function () use ($base, $pages): Generator {
yield $base.'/sitemap/pages.xml';
for ($page = 1; $page <= $pages; $page++) {
yield $base.'/sitemap/opportunities/'.$page.'.xml';
}
})();
return $this->response($xml, $locations, true);
}
public function pages(PublicSitemapXml $xml): StreamedResponse
{
$base = $this->base();
$paths = ['/', '/oportunidades', '/sectores', '/como-investir', '/documentos', '/apoio', '/simulador-fiscal', '/politica-de-privacidade', '/termos-de-uso'];
return $this->response($xml, array_map(fn ($path) => $base.$path, $paths));
}
public function opportunities(string $page, PublicSitemapXml $xml): StreamedResponse
{
$number = filter_var($page, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 1000000]]);
abort_if($number === false, 404);
$base = $this->base();
$records = Opportunity::published()->orderBy('id')->offset(($number - 1) * self::PAGE_SIZE)->limit(self::PAGE_SIZE)->get(['slug']);
abort_if($records->isEmpty(), 404);
return $this->response($xml, $records->map(fn ($record) => $base.'/oportunidades/'.rawurlencode($record->slug)));
}
private function base(): string
{
$url = rtrim((string) config('app.url'), '/');
$parts = parse_url($url);
abort_unless(filter_var($url, FILTER_VALIDATE_URL) && is_array($parts)
&& in_array($parts['scheme'] ?? null, ['http', 'https'], true)
&& ! isset($parts['query']) && ! isset($parts['fragment']) && ! isset($parts['user']) && ! isset($parts['pass']), 503);
return $url;
}
/** @param iterable<string> $locations */
private function response(PublicSitemapXml $xml, iterable $locations, bool $index = false): StreamedResponse
{
return response()->stream(static function () use ($xml, $locations, $index): void {
foreach ($xml->chunks($locations, $index) as $chunk) {
echo $chunk;
}
}, 200, ['Content-Type' => 'application/xml; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff']);
}
}
@@ -1,24 +1,20 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\SectorReconciliationExecution; use App\Services\SectorReconciliationExecution;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final class SectorReconciliationController extends Controller final class SectorReconciliationController extends Controller
{ {
public function preview(Request $request, SectorReconciliationExecution $execution): JsonResponse|Response public function preview(Request $request, SectorReconciliationExecution $execution)
{ {
$plan = $execution->preview($request); $plan = $execution->preview($request);
return $request->expectsJson() ? response()->json($plan)->header('Cache-Control', 'private, no-store') return $request->expectsJson() ? response()->json($plan)->header('Cache-Control', 'private, no-store')
: response()->view('management.sector-reconciliation', ['plan' => $plan, 'operationKey' => (string) Str::uuid()])->header('Cache-Control', 'private, no-store'); : response()->view('management.sector-reconciliation', ['plan' => $plan, 'operationKey' => (string)Str::uuid()])->header('Cache-Control', 'private, no-store');
} }
public function apply(Request $request, SectorReconciliationExecution $execution): JsonResponse public function apply(Request $request, SectorReconciliationExecution $execution)
{ {
return response()->json($execution->apply($request))->header('Cache-Control', 'private, no-store'); return response()->json($execution->apply($request))->header('Cache-Control', 'private, no-store');
} }
@@ -1,45 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\OpportunityTechnicalSupport;
use App\Services\TechnicalIncidentHandling;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
final class TechnicalSupportController extends Controller
{
public function index(Request $request, OpportunityTechnicalSupport $support): Response
{
$filters = $request->validate(['page' => ['nullable', 'integer', 'min:1'], 'status' => ['nullable', 'in:all,open,in_progress,resolved'], 'unread' => ['nullable', 'boolean']]);
$status = $filters['status'] ?? 'all';
$unread = $request->boolean('unread');
$incidents = $support->queue($request->user(), $status, $unread);
Inertia::encryptHistory();
return tap(Inertia::render('profiles/technical-support', ['actorId' => $request->user()->id, 'incidents' => $incidents, 'filters' => ['status' => $status, 'unread' => $unread]])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function show(Request $request, string $reference, TechnicalIncidentHandling $handling, OpportunityTechnicalSupport $support): Response
{
$filters = $request->validate(['history_page' => ['nullable', 'integer', 'min:1'], 'page' => ['nullable', 'integer', 'min:1'], 'status' => ['nullable', 'in:all,open,in_progress,resolved'], 'unread' => ['nullable', 'boolean']]);
$data = $handling->detail($request->user(), $reference);
Inertia::encryptHistory();
$query = array_filter(['page' => $request->integer('page') > 1 ? $request->integer('page') : null, 'status' => ($filters['status'] ?? 'all') !== 'all' ? $filters['status'] : null, 'unread' => $request->boolean('unread') ? 1 : null], fn ($v) => $v !== null);
return tap(Inertia::render('profiles/technical-incident', [...$data, 'notice' => $support->notice($request->user(), $reference), 'backUrl' => '/suporte-tecnico/incidentes'.($query !== [] ? '?'.http_build_query($query) : '')])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function decide(Request $request, string $reference, TechnicalIncidentHandling $handling): JsonResponse
{
return response()->json($handling->decide($request->user(), $reference, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function consulted(Request $request, string $reference, OpportunityTechnicalSupport $support): JsonResponse
{
return response()->json($support->markConsulted($request->user(), $reference))->header('Cache-Control', 'private, no-store');
}
}
@@ -5,32 +5,11 @@ namespace App\Http\Controllers;
use App\Services\TerritorialRegistry; use App\Services\TerritorialRegistry;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class TerritorialRegistryController extends Controller class TerritorialRegistryController extends Controller
{ {
public function editor(Request $request, TerritorialRegistry $registry): Response
{
abort_unless($registry->editorialAvailable(), 404);
$actor = $request->user();
$roles = array_values(array_filter(['editors', 'reviewers', 'publishers'], fn ($role) => in_array(strtolower($actor->email), config('territorial.'.$role, []), true)));
$context = $request->validate(['territory' => 'nullable|uuid', 'notice_version' => 'nullable|integer|min:1']);
return Inertia::render('management/territorial-editorial', ['roles' => $roles, 'actorId' => $actor->id, 'initialTerritory' => $context['territory'] ?? null, 'noticeVersion' => $context['notice_version'] ?? null])->toResponse($request);
}
public function preview(Request $request, TerritorialRegistry $registry, string $id): JsonResponse
{
abort_unless($registry->editorialAvailable(), 404);
return response()->json($registry->preview($id));
}
public function sources(Request $request): JsonResponse public function sources(Request $request): JsonResponse
{ {
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]); $data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]);
@@ -38,25 +17,13 @@ class TerritorialRegistryController extends Controller
return response()->json(DB::table('geo_sources')->orderBy('id')->cursorPaginate($data['limit'] ?? 20)->withQueryString()); return response()->json(DB::table('geo_sources')->orderBy('id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
} }
public function drafts(Request $request, TerritorialRegistry $registry): JsonResponse public function drafts(Request $request): JsonResponse
{ {
$data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]); $data = $request->validate(['limit' => ['nullable', 'integer', 'between:1,100'], 'cursor' => ['nullable', 'string', 'max:512']]);
$page = DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id')->whereNull('g.deleted_at') return response()->json(DB::table('geo_locations as g')->join('geo_territory_profiles as p', 'p.geo_location_id', '=', 'g.id')->whereNull('g.deleted_at')
->select(['g.id', 'g.name', 'g.code', 'g.location_type', 'g.parent_id', 'g.version_no', 'p.source_id', 'p.status', 'p.valid_from', 'p.valid_to', 'p.editor_id', 'p.reviewer_id', 'p.publisher_id']) ->select(['g.id', 'g.name', 'g.code', 'g.location_type', 'g.parent_id', 'g.version_no', 'p.source_id', 'p.status', 'p.valid_from', 'p.valid_to', 'p.editor_id', 'p.reviewer_id', 'p.publisher_id'])
->orderBy('g.id')->cursorPaginate($data['limit'] ?? 20)->withQueryString(); ->orderBy('g.id')->cursorPaginate($data['limit'] ?? 20)->withQueryString());
if ($registry->editorialAvailable()) {
abort_unless($page instanceof CursorPaginator, 503, 'Paginação territorial indisponível.');
$page->through(function ($row) use ($registry) {
$state = $registry->working($row->id);
return [...$state['location'], ...$state['profile'], 'id' => $row->id,
'public_version_no' => $state['public_version_no'], 'fresh' => $state['fresh'],
'private_revision' => $state['_editorial']['private'] ?? false];
});
}
return response()->json($page);
} }
public function index(Request $request, TerritorialRegistry $registry, ?string $collection = null): JsonResponse public function index(Request $request, TerritorialRegistry $registry, ?string $collection = null): JsonResponse
@@ -107,8 +74,7 @@ class TerritorialRegistryController extends Controller
{ {
$id = $registry->save($request->user(), $request->all(), $id); $id = $registry->save($request->user(), $request->all(), $id);
return response()->json(['id' => $id, 'status' => 'draft', 'version_no' => $registry->editorialAvailable() return response()->json(['id' => $id, 'status' => 'draft', 'version_no' => DB::table('geo_locations')->where('id', $id)->value('version_no')]);
? $registry->working($id)['location']['version_no'] : DB::table('geo_locations')->where('id', $id)->value('version_no')]);
} }
public function source(Request $request): JsonResponse public function source(Request $request): JsonResponse
@@ -129,15 +95,9 @@ class TerritorialRegistryController extends Controller
public function transition(Request $request, TerritorialRegistry $registry, string $id): JsonResponse public function transition(Request $request, TerritorialRegistry $registry, string $id): JsonResponse
{ {
$data = $request->validate(['action' => ['required', 'in:pending_review,in_review,approved,published,rejected,archived,withdrawn'], $data = $request->validate(['action' => ['required', 'in:pending_review,approved,published,rejected,archived'],
'version_no' => ['required', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000']]); 'version_no' => ['required', 'integer', 'min:1'], 'reason' => ['required', 'string', 'min:10', 'max:1000']]);
$registry->ensureWriteMode($id);
if ($registry->editorialAvailable() && $registry->hasEditorialHistory($id)) {
return response()->json($registry->transitionEditorial($request->user(), $id, $data));
}
abort_if(in_array($data['action'], ['in_review', 'withdrawn'], true), 422);
return DB::transaction(function () use ($request, $registry, $id, $data) { return DB::transaction(function () use ($request, $registry, $id, $data) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)'); DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first(); $location = DB::table('geo_locations')->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
+48 -178
View File
@@ -7,13 +7,9 @@ use App\Models\InvestmentSubmission;
use App\Models\User; use App\Models\User;
use App\Services\Ai\InvestorGuidance; use App\Services\Ai\InvestorGuidance;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\DeskAttachments;
use App\Services\DeskPaymentEvidence;
use App\Services\DeskServiceAvailability;
use App\Services\InstitutionalLinkMonitor; use App\Services\InstitutionalLinkMonitor;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use App\Services\InvestorContextConsumers; use App\Services\InvestorContextConsumers;
use App\Services\ScopedPortalAdministration;
use App\Services\VerifiedDocumentBinary; use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk; use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -25,12 +21,10 @@ use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class VirtualDeskController extends Controller class VirtualDeskController extends Controller
{ {
@@ -38,7 +32,7 @@ class VirtualDeskController extends Controller
{ {
$filters = $request->validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']); $filters = $request->validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']);
$installed = $desk->installed(); $installed = $desk->installed();
$services = $installed ? app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'effective_from' => $s->effective_from ?? null, 'definition' => json_decode($s->definition, true), 'source' => json_decode($s->source, true)])->values() : collect(); $services = $installed ? DB::table('desk_service_versions')->where('status', 'published')->orderBy('code')->orderByDesc('version')->get()->unique('code')->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'definition' => json_decode($s->definition, true), 'source' => json_decode($s->source, true)])->values() : collect();
$services = $services->filter(fn ($s) => (! ($filters['q'] ?? '') || str_contains(mb_strtolower(json_encode($s['definition'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), mb_strtolower($filters['q']))) $services = $services->filter(fn ($s) => (! ($filters['q'] ?? '') || str_contains(mb_strtolower(json_encode($s['definition'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), mb_strtolower($filters['q'])))
&& (! ($filters['theme'] ?? '') || $s['definition']['theme'] === $filters['theme']) && (! ($filters['profile'] ?? '') || $s['definition']['profile'] === $filters['profile']))->values(); && (! ($filters['theme'] ?? '') || $s['definition']['theme'] === $filters['theme']) && (! ($filters['profile'] ?? '') || $s['definition']['profile'] === $filters['profile']))->values();
$page = max(1, (int) $request->query('page', 1)); $page = max(1, (int) $request->query('page', 1));
@@ -53,7 +47,7 @@ class VirtualDeskController extends Controller
$actor = $request->user(); $actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id'); $organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$cases = DB::table('desk_cases')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->orderByDesc('updated_at')->get(['id', 'title', 'version', 'updated_at']); $cases = DB::table('desk_cases')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->orderByDesc('updated_at')->get(['id', 'title', 'version', 'updated_at']);
$queue = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')->whereNull('c.closed_at')->whereNotIn('r.status', ['draft', 'cancelled', 'closed'])->when(! $actor->can('manage-portal'), fn ($q) => $q->where('r.analyst_id', $actor->id)) $queue = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')->whereNotIn('r.status', ['draft', 'cancelled'])->when(! $actor->can('manage-portal'), fn ($q) => $q->where('r.analyst_id', $actor->id))
->orderByRaw('r.due_at ASC NULLS LAST')->get(['r.id', 'r.protocol', 'r.status', 'r.version', 'r.analyst_id', 'r.due_at', 'c.title']); ->orderByRaw('r.due_at ASC NULLS LAST')->get(['r.id', 'r.protocol', 'r.status', 'r.version', 'r.analyst_id', 'r.due_at', 'c.title']);
$data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(), $data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(),
'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->whereNull('deleted_at')->orderByDesc('created_at')->get(['id', 'reference', 'status', 'created_at']), 'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->whereNull('deleted_at')->orderByDesc('created_at')->get(['id', 'reference', 'status', 'created_at']),
@@ -78,23 +72,6 @@ class VirtualDeskController extends Controller
} }
public function showCase(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response public function showCase(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
{
$data = $this->caseViewData($request, $id, $desk);
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
}
public function preDossier(Request $request, string $id, VirtualDesk $desk): \Illuminate\Http\Response
{
return response()->view('desk.pre-dossier', $this->caseViewData($request, $id, $desk), 200, [
'Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff',
]);
}
/** Same authorised projection for the workspace and its preparation document.
* @return array<string,mixed>
*/
private function caseViewData(Request $request, string $id, VirtualDesk $desk): array
{ {
$case = $desk->case($request->user(), $id); $case = $desk->case($request->user(), $id);
$owner = $desk->owns($request->user(), $case, false); $owner = $desk->owns($request->user(), $case, false);
@@ -107,15 +84,14 @@ class VirtualDeskController extends Controller
'legacyRequest' => ($case->interest_request_id ?? null) ? DB::table('interest_requests as r')->leftJoin('users as u', 'u.id', '=', 'r.assigned_to')->where('r.id', $case->interest_request_id)->whereNull('r.deleted_at') 'legacyRequest' => ($case->interest_request_id ?? null) ? DB::table('interest_requests as r')->leftJoin('users as u', 'u.id', '=', 'r.assigned_to')->where('r.id', $case->interest_request_id)->whereNull('r.deleted_at')
->when(! $request->user()->can('manage-portal'), fn ($q) => $q->where('r.metadata->portal_user_id', $request->user()->id)) ->when(! $request->user()->can('manage-portal'), fn ($q) => $q->where('r.metadata->portal_user_id', $request->user()->id))
->first(['r.id', 'r.reference', 'r.status', 'r.next_action', 'r.next_action_due_at', 'u.name as responsible_name']) : null, ->first(['r.id', 'r.reference', 'r.status', 'r.next_action', 'r.next_action_due_at', 'u.name as responsible_name']) : null,
'owner' => $owner, 'canWrite' => ! $case->closed_at && $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), 'owner' => $owner, 'canWrite' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'),
'canChangeState' => (int) $case->user_id === $request->user()->id && $desk->owns($request->user(), $case), 'activity' => $owner ? $desk->caseActivity($case) : null,
'requests' => DB::table('desk_requests')->where('case_id', $id)->when(! $owner && ! $request->user()->can('manage-portal'), fn ($q) => $q->where('analyst_id', $request->user()->id))->orderByDesc('updated_at')->get(['id', 'status', 'protocol', 'service_version_id', 'version']), 'requests' => DB::table('desk_requests')->where('case_id', $id)->when(! $owner && ! $request->user()->can('manage-portal'), fn ($q) => $q->where('analyst_id', $request->user()->id))->orderByDesc('updated_at')->get(['id', 'status', 'protocol', 'service_version_id', 'version']),
'services' => app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version, 'effective_from' => $s->effective_from ?? null]), 'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'version', 'definition'])->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version]),
'documents' => $this->documents($id, $request->user()), 'events' => $this->events($id, $desk, $request->user()), 'stepStates' => config('virtual-desk.step_states'), 'states' => config('virtual-desk.request_states'), 'documents' => $this->documents($id, $request->user()), 'events' => $this->events($id, $desk, $request->user()), 'stepStates' => config('virtual-desk.step_states'), 'states' => config('virtual-desk.request_states'),
'links' => DB::table('desk_institutional_links')->get()->map(fn ($link) => [...(array) $link, 'monitor' => app(InstitutionalLinkMonitor::class)->status($link)]), 'source' => config('virtual-desk.source'), 'support' => config('portal.support'), 'links' => DB::table('desk_institutional_links')->get()->map(fn ($link) => [...(array) $link, 'monitor' => app(InstitutionalLinkMonitor::class)->status($link)]), 'source' => config('virtual-desk.source'), 'support' => config('portal.support'),
'guidance' => app(InvestorGuidance::class)->installed() ? app(InvestorGuidance::class)->view($request->user(), $id) : null]; 'guidance' => app(InvestorGuidance::class)->installed() ? app(InvestorGuidance::class)->view($request->user(), $id) : null];
return $data; return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
} }
/** @return Collection<int,\stdClass> */ /** @return Collection<int,\stdClass> */
@@ -123,46 +99,7 @@ class VirtualDeskController extends Controller
{ {
return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $case)->whereNull('d.deleted_at') return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $case)->whereNull('d.deleted_at')
->when(! app(VirtualDesk::class)->owns($actor, app(VirtualDesk::class)->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('l.request_id')->orWhereIn('l.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id')))) ->when(! app(VirtualDesk::class)->owns($actor, app(VirtualDesk::class)->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('l.request_id')->orWhereIn('l.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id'))))
->get(['l.id', 'l.document_id', 'l.request_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.sha256', 'd.media_type', 'd.size_bytes', 'd.version_no']); ->get(['l.id', 'l.document_id', 'l.request_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.sha256']);
}
public function availableDocuments(Request $request, string $id, VirtualDesk $desk): JsonResponse
{
$case = $desk->case($request->user(), $id, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.');
$data = $request->validate(['q' => 'nullable|string|max:120', 'offset' => 'nullable|integer|min:0']);
$offset = (int) ($data['offset'] ?? 0);
$actor = $request->user();
// Match the existing reuse authority: this dossier, own personal dossiers, or own uploads.
// A document's uploader does not inherit another dossier's reference or private context.
$sources = DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')
->where(fn ($q) => $q->where('c.id', $id)->orWhere(fn ($p) => $p->where('c.user_id', $actor->id)->whereNull('c.organization_id')))
->select(['l.id', 'l.document_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'c.title as origin_label']);
$query = DB::table('documents as d')->leftJoinSub($sources, 's', fn ($q) => $q->on('s.document_id', '=', 'd.id'))
->whereNull('d.deleted_at')->where(fn ($q) => $q->where('d.uploaded_by', $actor->id)->orWhereNotNull('s.id'));
$search = trim($data['q'] ?? '');
if ($search !== '') {
$like = '%'.addcslashes($search, '\\%_').'%';
$query->where(fn ($q) => $q->where('d.file_name', 'ilike', $like)->orWhere('s.source_reference', 'ilike', $like)->orWhere('s.origin_label', 'ilike', $like));
}
$rows = $query->orderBy('d.file_name')->orderBy('d.id')->orderBy('s.id')->offset($offset)->limit(26)
->get(['d.id as document_id', 'd.file_name', 'd.version_no', 'd.status', 'd.malware_scan_status', 'd.media_type', 'd.size_bytes', 'd.source_system', 's.id', 's.category', 's.stage', 's.source_reference', 's.valid_until', 's.origin_label']);
$items = $rows->take(25)->filter(function ($document) {
try {
app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment((object) [...(array) $document, 'id' => $document->document_id]);
return true;
} catch (HttpExceptionInterface $exception) {
if ($exception->getStatusCode() !== 403) {
throw $exception;
}
return false;
}
})->map(fn ($document) => Arr::except((array) $document, ['source_system']))->values();
$this->auditRead($request, 'desk_cases', $id);
return response()->json(['documents' => $items, 'next_offset' => $rows->count() > 25 ? $offset + 25 : null], 200, ['Cache-Control' => 'private, no-store']);
} }
/** @return Collection<int,array{id:mixed,request_id:mixed,action:mixed,version:mixed,created_at:mixed,name:mixed,payload:array<string,mixed>}> */ /** @return Collection<int,array{id:mixed,request_id:mixed,action:mixed,version:mixed,created_at:mixed,name:mixed,payload:array<string,mixed>}> */
@@ -173,13 +110,6 @@ class VirtualDeskController extends Controller
->orderBy('e.event_order')->get(['e.id', 'e.request_id', 'e.action', 'e.payload', 'e.version', 'e.created_at', 'u.name'])->map(fn ($e) => ['id' => $e->id, 'request_id' => $e->request_id, 'action' => $e->action, 'version' => $e->version, 'created_at' => $e->created_at, 'name' => $e->name, 'payload' => $desk->decode($e->payload)]); ->orderBy('e.event_order')->get(['e.id', 'e.request_id', 'e.action', 'e.payload', 'e.version', 'e.created_at', 'u.name'])->map(fn ($e) => ['id' => $e->id, 'request_id' => $e->request_id, 'action' => $e->action, 'version' => $e->version, 'created_at' => $e->created_at, 'name' => $e->name, 'payload' => $desk->decode($e->payload)]);
} }
public function caseState(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$result = $desk->changeCaseState($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($result) : back()->with('success', $result['replayed'] ? 'Operação já registada; o estado actual do dossier é apresentado.' : ($result['closed_at'] ? 'Dossier encerrado pelo dono. Os registos continuam disponíveis para consulta.' : 'Dossier reaberto pelo dono. Mantêm-se os estados próprios dos pedidos e das tarefas.'));
}
public function stage(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse public function stage(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{ {
$case = $desk->stage($request->user(), $id, $request->all()); $case = $desk->stage($request->user(), $id, $request->all());
@@ -215,21 +145,17 @@ class VirtualDeskController extends Controller
} }
} }
$this->auditRead($request, 'desk_requests', $id); $this->auditRead($request, 'desk_requests', $id);
$caseDocuments = $this->documents($case->id, $request->user());
$paymentEvidence = app(DeskPaymentEvidence::class)->forRequest($id);
$data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)], $data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths, 'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths,
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true), 'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
'serviceEffectiveFrom' => DB::table('desk_service_versions')->where('id', $row->service_version_id)->selectRaw(app(DeskServiceAvailability::class)->dateProjection())->first()->effective_from, 'owner' => $desk->owns($request->user(), $case), 'manager' => app(\App\Services\ScopedPortalAdministration::class)->deskDecision($request->user(),$row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'owner' => $desk->owns($request->user(), $case), 'manager' => app(ScopedPortalAdministration::class)->deskDecision($request->user(), $row), 'analyst' => (int) $row->analyst_id === $request->user()->id, 'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []),
'operationsEditable' => ! $case->closed_at && ! in_array($row->status, ['closed', 'cancelled'], true), 'fieldsEditable' => ! $case->closed_at && in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => $case->closed_at ? [] : config('virtual-desk.transitions.'.$row->status, []), 'returnReasons' => config('product-metrics.return_reasons'), 'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
'findings' => $desk->findings($payload, $definition), 'ai' => config('virtual-desk.ai'), 'documents' => $caseDocuments->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
'reusableDocuments' => $desk->owns($request->user(), $case) && ! $case->closed_at ? $caseDocuments->values() : [],
'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body), 'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body),
'kind' => $messageKinds[$m->id] ?? 'historical_unclassified']), 'kind' => $messageKinds[$m->id] ?? 'historical_unclassified']),
'events' => $this->events($case->id, $desk, $request->user())->filter(fn ($e) => $e['request_id'] === $id)->values(), 'events' => $this->events($case->id, $desk, $request->user())->filter(fn ($e) => $e['request_id'] === $id)->values(),
'revisions' => DB::table('desk_revisions')->where('request_id', $id)->orderByDesc('version')->get(['version', 'snapshot', 'created_at'])->map(fn ($r) => [...(array) $r, 'snapshot' => $desk->decode($r->snapshot)]), 'revisions' => DB::table('desk_revisions')->where('request_id', $id)->orderByDesc('version')->get(['version', 'snapshot', 'created_at'])->map(fn ($r) => [...(array) $r, 'snapshot' => $desk->decode($r->snapshot)]),
'payments' => DB::table('desk_payments')->where('request_id', $id)->get()->map(fn ($payment) => [...(array) $payment, 'document_evidence' => $paymentEvidence[$payment->id] ?? null]), 'payments' => DB::table('desk_payments')->where('request_id', $id)->get(),
'analysts' => $request->user()->can('manage-portal') ? User::orderBy('name')->get(['id', 'name']) : [], 'analysts' => $request->user()->can('manage-portal') ? User::orderBy('name')->get(['id', 'name']) : [],
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])]; 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])];
@@ -252,32 +178,24 @@ class VirtualDeskController extends Controller
public function assignments(Request $request): JsonResponse|Response public function assignments(Request $request): JsonResponse|Response
{ {
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403); abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
// Triage projection deliberately excludes payloads, documents, investor identity and case titles. // Triage projection deliberately excludes payloads, documents, investor identity and case titles.
$data = ['requests' => DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled', 'closed']) $data=['requests'=>DB::table('desk_requests')->whereNotIn('status',['draft','cancelled','closed'])->orderBy('created_at')->get(['id','protocol','status','version','analyst_id','due_at']),
->whereExists(fn ($q) => $q->selectRaw('1')->from('desk_cases')->whereColumn('desk_cases.id', 'desk_requests.case_id')->whereNull('desk_cases.closed_at')) 'analysts'=>User::whereNotNull('email_verified_at')->orderBy('name')->get(['id','name'])];
->orderBy('created_at')->get(['id', 'protocol', 'status', 'version', 'analyst_id', 'due_at']), return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
'analysts' => User::whereNotNull('email_verified_at')->orderBy('name')->get(['id', 'name'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
} }
public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{ {
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403); abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()), 403);
$data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required' => 'Explique o motivo da atribuição.', 'reason.min' => 'Explique o motivo da atribuição com pelo menos 3 caracteres.', 'analyst_id.required' => 'Seleccione o responsável.', 'analyst_id.exists' => 'Seleccione um responsável com conta disponível.']); $data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 3 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.exists'=>'Seleccione um responsável com conta disponível.']);
DB::transaction(function () use ($request, $id, $desk, $data) { DB::transaction(function () use ($request, $id, $desk, $data) {
$row = DB::table('desk_requests')->where('id', $id)->lockForUpdate()->first(); $row = DB::table('desk_requests')->where('id',$id)->lockForUpdate()->first();
abort_unless($row !== null, 404); abort_unless($row,404);
$case = DB::table('desk_cases')->where('id', $row->case_id)->lockForUpdate()->first(); $case = DB::table('desk_cases')->where('id',$row->case_id)->first();
abort_unless($case !== null, 404); abort_unless($case,404);
$desk->assertOpen($case); if ((int)$row->version !== (int)$data['version']) throw \Illuminate\Validation\ValidationException::withMessages(['version'=>'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
if ((int) $row->version !== (int) $data['version']) { if (in_array($row->status,['draft','cancelled','closed'],true)) throw \Illuminate\Validation\ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
throw ValidationException::withMessages(['version' => 'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
}
if (in_array($row->status, ['draft', 'cancelled', 'closed'], true)) {
throw ValidationException::withMessages(['status' => 'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
}
$analyst = User::where('id', $data['analyst_id'])->firstOrFail(); $analyst = User::where('id', $data['analyst_id'])->firstOrFail();
abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.'); abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.');
abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.'); abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.');
@@ -293,8 +211,7 @@ class VirtualDeskController extends Controller
$data = $request->validate(['body' => 'required|string|min:1|max:10000', 'client_key' => 'required|uuid']); $data = $request->validate(['body' => 'required|string|min:1|max:10000', 'client_key' => 'required|uuid']);
DB::transaction(function () use ($request, $id, $desk, $data) { DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true); $row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id, false, true); $case = $desk->case($request->user(), $row->case_id);
$desk->assertOpen($case);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409, 'Pedido encerrado.'); abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409, 'Pedido encerrado.');
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403); abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
$existing = DB::table('desk_messages')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('client_key', $data['client_key'])->first(); $existing = DB::table('desk_messages')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('client_key', $data['client_key'])->first();
@@ -321,10 +238,7 @@ class VirtualDeskController extends Controller
$this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded'); $this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded');
if ($format === 'html') { if ($format === 'html') {
$definition = $desk->definition($row); return response()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $desk->definition($row)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
return response()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $definition,
'applicableFields' => $desk->applicableFields($snapshot['payload'] ?? [], $definition)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
} }
return response()->streamDownload(function () use ($row, $snapshot) { return response()->streamDownload(function () use ($row, $snapshot) {
@@ -336,90 +250,49 @@ class VirtualDeskController extends Controller
public function attach(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse public function attach(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{ {
$data = $request->validate(['version' => 'required|integer', 'file' => 'nullable|required_without:document_id|file|mimetypes:application/pdf,image/png,image/jpeg,text/plain|max:10240', $data = $request->validate(['version' => 'required|integer', 'file' => 'nullable|required_without:document_id|file|mimetypes:application/pdf,image/png,image/jpeg,text/plain|max:10240',
'document_id' => 'nullable|required_without:file|required_with:reuse_link_id|prohibits:file|uuid', 'reuse_link_id' => 'nullable|uuid|prohibits:file', 'category' => 'nullable|required_without:field_key|string|max:80', 'field_key' => 'nullable|string|max:64', 'document_id' => 'nullable|required_without:file|uuid', 'category' => 'required|string|max:80', 'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'required|string|max:3000', 'consent' => 'accepted']);
'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|required_with:field_key|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'nullable|required_without:document_id|string|max:3000', 'consent' => 'accepted']);
$key = null; $key = null;
$newId = null; $newId = null;
$linkId = null;
$reusedLink = false;
try { try {
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId, &$linkId, &$reusedLink) { DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId) {
$row = ($data['request_id'] ?? null) ? $desk->request($request->user(), $data['request_id'], true, true) : null;
$case = $desk->case($request->user(), $id, true, true); $case = $desk->case($request->user(), $id, true, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.'); abort_if($case->closed_at, 409, 'Dossier encerrado.');
abort_unless((int) $case->version === (int) $data['version'], 409); abort_unless((int) $case->version === (int) $data['version'], 409);
if ($data['stage'] ?? null) { if ($data['stage'] ?? null) {
abort_unless(array_key_exists($data['stage'], config('virtual-desk.steps')), 422); abort_unless(array_key_exists($data['stage'], config('virtual-desk.steps')), 422);
} }
if ($row) { if ($data['request_id'] ?? null) {
$row = $desk->request($request->user(), $data['request_id'], true, true);
abort_unless($row->case_id === $id && in_array($row->status, ['draft', 'returned'], true), 409, 'Anexos de pedidos submetidos são imutáveis; use a devolução para corrigir.'); abort_unless($row->case_id === $id && in_array($row->status, ['draft', 'returned'], true), 409, 'Anexos de pedidos submetidos são imutáveis; use a devolução para corrigir.');
} }
$definition = $row ? $desk->definition($row) : []; abort_if(DB::table('desk_documents')->where('case_id', $id)->count() >= 100, 422, 'Limite de anexos do dossier atingido.');
$field = ($data['field_key'] ?? null) ? app(DeskAttachments::class)->field($definition, $data['field_key']) : null;
if ($field) {
if (isset($data['stage']) && $data['stage'] !== $definition['stage']) {
throw ValidationException::withMessages(['stage' => 'A utilização nesta pergunta pertence à etapa do pedido; a origem do documento é conservada separadamente.']);
}
abort_unless(app(DeskAttachments::class)->scopedLinksInstalled(), 409, 'Instale a actualização dos vínculos documentais para este formulário.');
if ($row->status === 'returned' && ! in_array($data['field_key'], json_decode($row->editable_fields ?? '[]', true), true)) {
throw ValidationException::withMessages(['field_key' => 'Esta pergunta não está autorizada para correcção.']);
}
}
$document = null;
if ($request->hasFile('file')) { if ($request->hasFile('file')) {
$file = $request->file('file'); $file = $request->file('file');
if ($field) {
app(DeskAttachments::class)->checkMetadata((object) ['media_type' => $file->getMimeType(), 'size_bytes' => $file->getSize()], $field, 'file');
}
$hash = hash_file('sha256', $file->getRealPath()); $hash = hash_file('sha256', $file->getRealPath());
$document = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->where('d.sha256', $hash)->first(['d.*']); $duplicate = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.sha256', $hash)->first();
if (! $document) { if ($duplicate) {
abort_if(DB::table('desk_documents')->where('case_id', $id)->distinct()->count('document_id') >= 100, 422, 'Limite de ficheiros distintos do dossier atingido.'); return;
$newId = (string) Str::uuid();
$key = $file->storeAs('virtual-desk/'.$id, $newId, 'local');
abort_unless(is_string($key) && $key !== '', 500);
DB::table('documents')->insert(['id' => $newId, 'source_system' => 'virtual-desk', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']);
$document = DB::table('documents')->where('id', $newId)->firstOrFail();
} }
$newId = (string) Str::uuid();
$key = $file->storeAs('virtual-desk/'.$id, $newId, 'local');
abort_unless(is_string($key) && $key !== '', 500);
DB::table('documents')->insert(['id' => $newId, 'source_system' => 'virtual-desk', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']);
$documentId = $newId;
} else { } else {
$document = DB::table('documents')->where('id', $data['document_id'])->whereNull('deleted_at')->first(); $documentId = $data['document_id'];
$document = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
abort_unless($document !== null, 404); abort_unless($document !== null, 404);
$owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)->exists() $owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $documentId)->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')->exists();
|| DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $document->id)->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')->exists();
abort_unless($owned, 403, 'Não há autorização para reutilizar este documento.'); abort_unless($owned, 403, 'Não há autorização para reutilizar este documento.');
if (DB::table('desk_documents')->where('case_id', $id)->where('document_id', $documentId)->exists()) {
return;
}
} }
if ($field) { DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $id, 'document_id' => $documentId, 'request_id' => $data['request_id'] ?? null,
app(DeskAttachments::class)->checkMetadata($document, $field, 'document_id', true); 'category' => $data['category'], 'stage' => $data['stage'] ?? null, 'valid_until' => $data['valid_until'] ?? null, 'source_reference' => $data['source_reference'], 'linked_by' => $request->user()->id]);
}
app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment($document);
$existing = DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)
->where(fn ($q) => ($data['request_id'] ?? null) ? $q->whereNull('request_id')->orWhere('request_id', $data['request_id']) : $q->whereNull('request_id'))->orderByRaw('request_id IS NULL DESC')->first();
if ($existing) {
$linkId = $existing->id;
$reusedLink = true;
return;
}
if (! app(DeskAttachments::class)->scopedLinksInstalled() && DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)->exists()) {
throw ValidationException::withMessages(['document_id' => 'Instale a actualização dos vínculos para reutilizar o ficheiro noutro pedido, preservando o âmbito anterior.']);
}
$inCase = DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)->exists();
abort_if(! $inCase && DB::table('desk_documents')->where('case_id', $id)->distinct()->count('document_id') >= 100, 422, 'Limite de ficheiros distintos do dossier atingido.');
$sourceLink = DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $document->id)
->where(fn ($q) => $q->where('l.case_id', $id)->orWhere(fn ($p) => $p->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')))
->when($data['reuse_link_id'] ?? null, fn ($q, $sourceId) => $q->where('l.id', $sourceId))->orderBy('l.created_at')->orderBy('l.id')->first(['l.*']);
abort_if(($data['reuse_link_id'] ?? null) && ! $sourceLink, 403, 'Não há autorização para reutilizar esta referência documental.');
$source = $data['source_reference'] ?? $sourceLink?->source_reference;
if (! is_string($source) || trim($source) === '') {
throw ValidationException::withMessages(['source_reference' => 'Indique a proveniência deste documento; não existe uma referência autorizada para reutilizar.']);
}
$linkId = (string) Str::uuid();
DB::table('desk_documents')->insert(['id' => $linkId, 'case_id' => $id, 'document_id' => $document->id, 'request_id' => $data['request_id'] ?? null,
'category' => $field ? Str::limit($field['label'], 80, '') : $data['category'], 'stage' => $data['stage'] ?? ($field ? $definition['stage'] : $sourceLink?->stage), 'valid_until' => array_key_exists('valid_until', $data) ? $data['valid_until'] : $sourceLink?->valid_until, 'source_reference' => $source, 'linked_by' => $request->user()->id]);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]); DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
$desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $document->id, 'link_id' => $linkId, 'source' => $source, 'consent' => true, 'field_key' => $data['field_key'] ?? null, 'source_link_id' => $sourceLink?->id, $desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $documentId, 'source' => $data['source_reference'], 'consent' => true]);
'reused_source' => $sourceLink ? ['source_reference' => $sourceLink->source_reference, 'category' => $sourceLink->category, 'stage' => $sourceLink->stage, 'valid_until' => $sourceLink->valid_until, 'document_id' => $document->id, 'version_no' => $document->version_no, 'sha256' => trim($document->sha256)] : null], $row);
}); });
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($key) { if ($key) {
@@ -430,10 +303,7 @@ class VirtualDeskController extends Controller
ScanDocumentForMalware::dispatch($newId)->afterCommit(); ScanDocumentForMalware::dispatch($newId)->afterCommit();
} }
$available = $this->documents($id, $request->user()); return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Documento privado associado; consulte o estado da análise.');
return $request->expectsJson() ? response()->json(['ok' => true, 'link_id' => $linkId, 'reused_link' => $reusedLink, 'case_version' => (int) DB::table('desk_cases')->where('id', $id)->value('version'),
'documents' => $available->filter(fn ($d) => ! ($data['request_id'] ?? null) || ! $d->request_id || $d->request_id === $data['request_id'])->values(), 'reusable_documents' => $desk->owns($request->user(), $desk->case($request->user(), $id)) ? $available->values() : []]) : back()->with('success', $reusedLink ? 'Este documento já estava associado; conservou-se a referência registada.' : 'Documento privado associado; consulte o estado da análise.');
} }
public function download(Request $request, string $id, string $document, VirtualDesk $desk): BinaryFileResponse|\Illuminate\Http\Response public function download(Request $request, string $id, string $document, VirtualDesk $desk): BinaryFileResponse|\Illuminate\Http\Response
@@ -2,8 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\DeskServiceAvailability;
use App\Services\DeskServiceEditor;
use App\Services\VirtualDeskInstallation; use App\Services\VirtualDeskInstallation;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -81,22 +79,19 @@ class VirtualDeskInstallationController extends Controller
DB::select('select pg_advisory_xact_lock(261008, 2)'); DB::select('select pg_advisory_xact_lock(261008, 2)');
$definition = config('virtual-desk.services.'.$data['code']); $definition = config('virtual-desk.services.'.$data['code']);
$existing = DB::table('desk_service_versions')->where('code', $data['code'])->orderByDesc('version')->first(); $existing = DB::table('desk_service_versions')->where('code', $data['code'])->orderByDesc('version')->first();
if ($existing && $existing->status === 'published' && app(DeskServiceEditor::class)->sameDefinition(json_decode($existing->definition, true, 512, JSON_THROW_ON_ERROR), $definition)) { if ($existing && $existing->status === 'published' && json_decode($existing->definition, true) === $definition) {
return $existing; return $existing;
} }
$id = (string) Str::uuid(); $id = (string) Str::uuid();
$effective = app(DeskServiceAvailability::class)->scheduled() ? now()->toDateString() : null;
DB::table('desk_service_versions')->insert(['id' => $id, 'code' => $data['code'], 'version' => ($existing->version ?? 0) + 1, 'status' => 'published', DB::table('desk_service_versions')->insert(['id' => $id, 'code' => $data['code'], 'version' => ($existing->version ?? 0) + 1, 'status' => 'published',
...($effective ? ['effective_from' => $effective] : []),
'definition' => json_encode($definition), 'source' => json_encode(config('virtual-desk.source')), 'author_id' => $request->user()->id, 'definition' => json_encode($definition), 'source' => json_encode(config('virtual-desk.source')), 'author_id' => $request->user()->id,
'published_by' => $request->user()->id, 'published_at' => now(), 'created_at' => now(), 'updated_at' => now()]); 'published_by' => $request->user()->id, 'published_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_service_versions', 'entity_id' => $id, 'actor_user_id' => $request->user()->id, DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_service_versions', 'entity_id' => $id, 'actor_user_id' => $request->user()->id,
'action' => 'internal_service_published', 'after_data' => json_encode(['code' => $data['code'], 'nature' => 'digital_preparation_not_official_form', 'effective_from' => $effective])]); 'action' => 'internal_service_published', 'after_data' => json_encode(['code' => $data['code'], 'nature' => 'digital_preparation_not_official_form'])]);
return DB::table('desk_service_versions')->where('id', $id)->first(); return DB::table('desk_service_versions')->where('id', $id)->first();
}); });
return $request->expectsJson() ? response()->json(['id' => $row->id, 'effective_from' => $row->effective_from ?? null], 201) return $request->expectsJson() ? response()->json(['id' => $row->id], 201) : back()->with('success', 'Serviço de preparação interna publicado.');
: back()->with('success', 'Serviço de preparação interna publicado'.(($row->effective_from ?? null) ? ' com entrada em vigor em '.$row->effective_from : '').'.');
} }
} }
@@ -3,16 +3,14 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\Ai\InvestorGuidanceEvidence; use App\Services\Ai\InvestorGuidanceEvidence;
use App\Services\DeskPaymentEvidence;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk; use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Manual operations use the same requests, documents and encrypted audit history. */ /** Manual operations use the same requests, documents and encrypted audit history. */
class VirtualDeskOperationsController extends Controller class VirtualDeskOperationsController extends Controller
@@ -27,19 +25,16 @@ class VirtualDeskOperationsController extends Controller
abort_unless(! $desk->owns($request->user(), $case, false) && ($request->user()->can('manage-portal') || ($row && (int) $row->analyst_id === $request->user()->id)), 403); abort_unless(! $desk->owns($request->user(), $case, false) && ($request->user()->can('manage-portal') || ($row && (int) $row->analyst_id === $request->user()->id)), 403);
} }
private function cleanDocument(string $caseId, string $documentId, ?string $requestId, ?string $linkId = null): \stdClass private function cleanDocument(string $caseId, string $documentId, ?string $requestId): \stdClass
{ {
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id') $doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
->where('l.case_id', $caseId)->where('d.id', $documentId)->whereNull('d.deleted_at') ->where('l.case_id', $caseId)->where('d.id', $documentId)->whereNull('d.deleted_at')
->when($linkId, fn ($q) => $q->where('l.id', $linkId))
->where(fn ($q) => $q->whereNull('l.request_id')->when($requestId, fn ($p) => $p->orWhere('l.request_id', $requestId))) ->where(fn ($q) => $q->whereNull('l.request_id')->when($requestId, fn ($p) => $p->orWhere('l.request_id', $requestId)))
->first(['d.*', 'l.id as link_id']); ->first(['d.*', 'l.id as link_id']);
abort_unless($doc !== null && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 422, 'Documento indisponível para conferência: verifique autorização, integridade e análise antimalware.'); abort_unless($doc !== null && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 422, 'Documento indisponível para conferência: verifique autorização, integridade e análise antimalware.');
try { $path = realpath(Storage::disk('local')->path($doc->storage_key));
app(VerifiedDocumentBinary::class)->respond($doc); $root = realpath(Storage::disk('local')->path(''));
} catch (HttpExceptionInterface) { abort_unless($path && $root && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path) && is_string($hash = hash_file('sha256', $path)) && hash_equals(trim($doc->sha256), $hash), 422, 'O ficheiro original ou a sua integridade não pôde ser confirmado.');
abort(422, 'O original, a sua autorização ou integridade não pôde ser confirmado.');
}
return $doc; return $doc;
} }
@@ -48,16 +43,15 @@ class VirtualDeskOperationsController extends Controller
{ {
$data = $request->validate(['version' => 'required|integer|min:1', 'request_id' => 'nullable|uuid', 'outcome' => 'required|in:internal_verified,internal_issue,contradiction_identified', 'what_checked' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000', 'supports' => 'nullable|array|max:4', 'supports.*' => ['string', Rule::in(InvestorGuidanceEvidence::FIELDS)]]); $data = $request->validate(['version' => 'required|integer|min:1', 'request_id' => 'nullable|uuid', 'outcome' => 'required|in:internal_verified,internal_issue,contradiction_identified', 'what_checked' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000', 'supports' => 'nullable|array|max:4', 'supports.*' => ['string', Rule::in(InvestorGuidanceEvidence::FIELDS)]]);
DB::transaction(function () use ($request, $id, $document, $desk, $data) { DB::transaction(function () use ($request, $id, $document, $desk, $data) {
$desk->case($request->user(), $id); $case = $desk->case($request->user(), $id, false, true);
abort_unless(! $case->closed_at && (int) $case->version === (int) $data['version'], 409);
$link = DB::table('desk_documents')->where('case_id', $id)->where('id', $document)->first(); $link = DB::table('desk_documents')->where('case_id', $id)->where('id', $document)->first();
abort_unless($link !== null, 404); abort_unless($link !== null, 404);
$requestId = $link->request_id ?: ($data['request_id'] ?? null); $requestId = $link->request_id ?: ($data['request_id'] ?? null);
$row = $requestId ? $desk->request($request->user(), $requestId, false, true) : null; $row = $requestId ? $desk->request($request->user(), $requestId, false, true) : null;
$case = $desk->case($request->user(), $id, false, true);
abort_unless(! $case->closed_at && (int) $case->version === (int) $data['version'], 409);
abort_if($row && $row->case_id !== $id, 422); abort_if($row && $row->case_id !== $id, 422);
$this->reviewer($request, $desk, $case, $row); $this->reviewer($request, $desk, $case, $row);
$doc = $this->cleanDocument($id, $link->document_id, $requestId, $link->id); $doc = $this->cleanDocument($id, $link->document_id, $requestId);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]); DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
$desk->event(DB::table('desk_cases')->where('id', $id)->firstOrFail(), $request->user(), 'document_reviewed', [ $desk->event(DB::table('desk_cases')->where('id', $id)->firstOrFail(), $request->user(), 'document_reviewed', [
...$data, 'document_id' => $doc->id, 'link_id' => $link->id, 'sha256' => trim($doc->sha256), 'document_version' => (int) $doc->version_no, 'reviewer_id' => $request->user()->id, ...$data, 'document_id' => $doc->id, 'link_id' => $link->id, 'sha256' => trim($doc->sha256), 'document_version' => (int) $doc->version_no, 'reviewer_id' => $request->user()->id,
@@ -70,58 +64,26 @@ class VirtualDeskOperationsController extends Controller
public function payment(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse public function payment(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{ {
$data = $request->validate(['reference' => 'required|string|max:255', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/', 'numeric', 'gt:0'], 'currency' => 'required|in:MZN,USD,EUR', 'source_reference' => 'required|string|min:3|max:3000', 'document_id' => 'nullable|uuid', 'document_link_id' => 'nullable|uuid', 'consent' => 'accepted']); $data = $request->validate(['reference' => 'required|string|max:255', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/', 'numeric', 'gt:0'], 'currency' => 'required|in:MZN,USD,EUR', 'source_reference' => 'required|string|min:3|max:3000', 'document_id' => 'nullable|uuid', 'consent' => 'accepted']);
DB::transaction(function () use ($request, $id, $desk, $data) { DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, true, true); $row = $desk->request($request->user(), $id, true, true);
$case = $desk->case($request->user(), $row->case_id, true, true); $case = $desk->case($request->user(), $row->case_id);
abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409); abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
$evidence = app(DeskPaymentEvidence::class)->snapshot($case->id, $id, $data); if ($data['document_id'] ?? null) {
abort_unless(DB::table('desk_documents')->where('case_id', $case->id)->where('document_id', $data['document_id'])->where(fn ($q) => $q->whereNull('request_id')->orWhere('request_id', $id))->exists(), 422);
}
// References are supplied by the investor; no bank reference or fee is generated. // References are supplied by the investor; no bank reference or fee is generated.
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['virtual-desk-payment:'.$data['reference']]);
$existing = DB::table('desk_payments')->where('reference', $data['reference'])->first(); $existing = DB::table('desk_payments')->where('reference', $data['reference'])->first();
if ($existing) { if ($existing) {
$parts = explode('.', (string) $data['amount']); $parts = explode('.', (string) $data['amount']);
$amount = (ltrim($parts[0], '0') ?: '0').'.'.str_pad($parts[1] ?? '', 2, '0'); $amount = (ltrim($parts[0], '0') ?: '0').'.'.str_pad($parts[1] ?? '', 2, '0');
$saved = app(DeskPaymentEvidence::class)->forRequest($id)[$existing->id] ?? null; abort_unless($existing->request_id === $id && (string) $existing->amount === $amount && $existing->currency === $data['currency'] && $existing->source_reference === $data['source_reference'] && $existing->document_id === ($data['document_id'] ?? null), 409);
abort_unless($existing->request_id === $id && (string) $existing->amount === $amount && $existing->currency === $data['currency'] && $existing->source_reference === $data['source_reference'] && $existing->document_id === ($evidence['document_id'] ?? null)
&& (! ($data['document_link_id'] ?? null) || ($saved['link_id'] ?? null) === $data['document_link_id']), 409);
return; return;
} }
$payment = (string) Str::uuid(); $payment = (string) Str::uuid();
DB::table('desk_payments')->insert(['id' => $payment, 'request_id' => $id, 'reference' => $data['reference'], 'amount' => $data['amount'], 'currency' => $data['currency'], 'source_reference' => $data['source_reference'], 'document_id' => $evidence['document_id'] ?? null, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]); DB::table('desk_payments')->insert(['id' => $payment, 'request_id' => $id, 'reference' => $data['reference'], 'amount' => $data['amount'], 'currency' => $data['currency'], 'source_reference' => $data['source_reference'], 'document_id' => $data['document_id'] ?? null, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_declared', ['payment_id' => $payment, 'payment_version' => 1, 'document_evidence' => $evidence, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row); $desk->event($case, $request->user(), 'payment_declared', ['payment_id' => $payment, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row);
});
return $this->result($request);
}
public function paymentDocument(Request $request, string $id, string $payment, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'document_link_id' => 'required|uuid', 'reason' => 'required|string|min:3|max:3000', 'consent' => 'accepted', 'client_key' => 'required|uuid']);
$data['version'] = (int) $data['version'];
$data['consent'] = true;
DB::transaction(function () use ($request, $id, $payment, $desk, $data) {
$row = $desk->request($request->user(), $id, true, true);
$case = $desk->case($request->user(), $row->case_id, true, true);
abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409);
$record = DB::table('desk_payments')->where('id', $payment)->where('request_id', $id)->lockForUpdate()->first();
abort_unless($record !== null, 404);
foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'payment_document_linked')->get(['payload']) as $event) {
$saved = $desk->decode($event->payload);
if (($saved['client_key'] ?? null) === $data['client_key']) {
abort_unless(($saved['payment_id'] ?? null) === $payment && ($saved['operation'] ?? null) === $data, 409, 'A operação já foi utilizada com outros dados. Inicie uma nova associação.');
return;
}
}
abort_unless((int) $record->version === (int) $data['version'] && in_array($record->status, ['pending', 'internal_issue'], true), 409, 'O registo mudou ou já está conferido. Actualize antes de associar outro comprovativo.');
$evidence = app(DeskPaymentEvidence::class)->snapshot($case->id, $id, $data);
abort_unless($evidence !== null, 422);
$previous = app(DeskPaymentEvidence::class)->forRequest($id)[$payment] ?? null;
DB::table('desk_payments')->where('id', $payment)->update(['document_id' => $evidence['document_id'], 'status' => 'pending', 'reason' => null, 'verified_by' => null, 'version' => $record->version + 1, 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_document_linked', ['payment_id' => $payment, 'payment_version' => $record->version + 1, 'previous_document_id' => $record->document_id, 'previous_document_evidence' => $previous,
'document_evidence' => $evidence, 'client_key' => $data['client_key'], 'operation' => $data, 'reason' => $data['reason'], 'consent' => true, 'scope' => 'documentary_correction_no_amount_change_or_payment_confirmation'], $row);
}); });
return $this->result($request); return $this->result($request);
@@ -132,21 +94,19 @@ class VirtualDeskOperationsController extends Controller
$data = $request->validate(['version' => 'required|integer|min:1', 'status' => 'required|in:internal_verified,internal_issue', 'reason' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000']); $data = $request->validate(['version' => 'required|integer|min:1', 'status' => 'required|in:internal_verified,internal_issue', 'reason' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000']);
DB::transaction(function () use ($request, $id, $payment, $desk, $data) { DB::transaction(function () use ($request, $id, $payment, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true); $row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id, false, true); $case = $desk->case($request->user(), $row->case_id);
$this->reviewer($request, $desk, $case, $row); $this->reviewer($request, $desk, $case, $row);
abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409); abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
$record = DB::table('desk_payments')->where('request_id', $id)->where('id', $payment)->lockForUpdate()->first(); $record = DB::table('desk_payments')->where('request_id', $id)->where('id', $payment)->lockForUpdate()->first();
abort_unless($record !== null, 404); abort_unless($record !== null, 404);
abort_unless((int) $record->version === (int) $data['version'], 409); abort_unless((int) $record->version === (int) $data['version'], 409);
$evidence = app(DeskPaymentEvidence::class)->forRequest($id)[$payment] ?? null;
$doc = null; $doc = null;
if ($data['status'] === 'internal_verified') { if ($data['status'] === 'internal_verified') {
abort_unless($record->document_id, 422, 'A conferência exige um comprovativo autorizado.'); abort_unless($record->document_id, 422, 'A conferência exige um comprovativo autorizado.');
$doc = $this->cleanDocument($case->id, $record->document_id, $id, $evidence['link_id'] ?? null); $doc = $this->cleanDocument($case->id, $record->document_id, $id);
abort_if($evidence && ($evidence['document_id'] !== $doc->id || $evidence['document_version'] !== (int) $doc->version_no || ! hash_equals($evidence['sha256'], trim($doc->sha256))), 422, 'O original já não corresponde ao comprovativo associado. Registe a correcção antes de conferir.');
} }
DB::table('desk_payments')->where('id', $payment)->update(['status' => $data['status'], 'reason' => $data['reason'], 'verified_by' => $request->user()->id, 'version' => $record->version + 1, 'updated_at' => now()]); DB::table('desk_payments')->where('id', $payment)->update(['status' => $data['status'], 'reason' => $data['reason'], 'verified_by' => $request->user()->id, 'version' => $record->version + 1, 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_reviewed', [...$data, 'payment_id' => $payment, 'payment_version' => $record->version + 1, 'document_id' => $record->document_id, 'document_evidence' => $evidence, 'sha256' => $doc ? trim($doc->sha256) : null, 'reviewer_id' => $request->user()->id, 'scope' => 'internal_proof_check_no_bank_confirmation'], $row); $desk->event($case, $request->user(), 'payment_reviewed', [...$data, 'payment_id' => $payment, 'document_id' => $doc?->id, 'sha256' => $doc ? trim($doc->sha256) : null, 'reviewer_id' => $request->user()->id, 'scope' => 'internal_proof_check_no_bank_confirmation'], $row);
}); });
return $this->result($request); return $this->result($request);
@@ -157,9 +117,9 @@ class VirtualDeskOperationsController extends Controller
$data = $request->validate(['channel' => 'required|in:phone,email,sms,whatsapp,in_person', 'direction' => 'required|in:received,sent', 'occurred_at' => 'required|date|before_or_equal:now', 'summary' => 'required|string|min:3|max:5000', 'evidence' => 'required|string|min:3|max:3000', 'client_key' => 'required|uuid']); $data = $request->validate(['channel' => 'required|in:phone,email,sms,whatsapp,in_person', 'direction' => 'required|in:received,sent', 'occurred_at' => 'required|date|before_or_equal:now', 'summary' => 'required|string|min:3|max:5000', 'evidence' => 'required|string|min:3|max:3000', 'client_key' => 'required|uuid']);
DB::transaction(function () use ($request, $id, $desk, $data) { DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true); $row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id, false, true); $case = $desk->case($request->user(), $row->case_id);
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403); abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409); abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'manual_communication_recorded')->get(['payload']) as $event) { foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'manual_communication_recorded')->get(['payload']) as $event) {
$saved = $desk->decode($event->payload); $saved = $desk->decode($event->payload);
if (($saved['client_key'] ?? null) === $data['client_key']) { if (($saved['client_key'] ?? null) === $data['client_key']) {
@@ -1,25 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Services\PublicSearchMetrics;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class CollectPublicSearchMetrics
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$candidate = $request->attributes->get(PublicSearchMetrics::ATTRIBUTE);
$request->attributes->remove(PublicSearchMetrics::ATTRIBUTE);
$prefetch = str_contains(strtolower($request->header('Purpose', '').' '.$request->header('Sec-Purpose', '')), 'prefetch') || $request->header('X-Inertia-Prefetch') === 'true';
$privacySignal = $request->header('DNT') === '1' || $request->header('Sec-GPC') === '1';
if ($request->isMethod('GET') && $response->getStatusCode() === 200 && ! $prefetch && ! $privacySignal && is_array($candidate) && isset($candidate['channel'], $candidate['empty']) && is_string($candidate['channel']) && is_bool($candidate['empty'])) {
app(PublicSearchMetrics::class)->record($candidate['channel'], $candidate['empty']);
}
return $response;
}
}
@@ -12,7 +12,7 @@ class EnsureDocumentManagement
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$actor = $request->user(); $actor = $request->user();
abort_unless($actor !== null, 403); abort_unless($actor, 403);
$access = app(DocumentManagementAccess::class); $access = app(DocumentManagementAccess::class);
if ($access->full($actor)) { if ($access->full($actor)) {
return $next($request); // Preserve existing management controls. return $next($request); // Preserve existing management controls.
@@ -1,50 +1,37 @@
<?php <?php
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** Environment reservation is not a portal identity or a business permission. */ /** Environment reservation is not a portal identity or a business permission. */
class EnsureHomologationAccess class EnsureHomologationAccess
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (! config('portal.homologation_only', false)) { if (!config('portal.homologation_only', false)) return $next($request);
return $next($request); $mode=config('portal.homologation_access_mode', 'reviewers');
} if ($mode==='perimeter') {
$mode = config('portal.homologation_access_mode', 'reviewers'); // Set only by the trusted server after its environment-access check.
if ($mode === 'perimeter') { // A client HTTP_INVESTMOZ_* header is deliberately not accepted.
// Set only by the trusted server after its environment-access check. abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS')==='granted',403,
// A client HTTP_INVESTMOZ_* header is deliberately not accepted. 'Este ambiente de homologação tem acesso reservado à Tempo.');
abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS') === 'granted', 403, $response=$next($request);
'Este ambiente de homologação tem acesso reservado à Tempo.'); $response->headers->set('Cache-Control','private, no-store');
$response = $next($request); $response->headers->set('X-Robots-Tag','noindex, nofollow, noarchive');
$response->headers->set('Cache-Control', 'private, no-store'); return $response;
$response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive'); }
abort_unless($mode==='reviewers',503,'A reserva da homologação não está configurada.');
return $response; // Compatibility until an independent perimeter is explicitly configured.
} if ($request->is('login','logout','register','forgot-password','reset-password','reset-password/*',
abort_unless($mode === 'reviewers', 503, 'A reserva da homologação não está configurada.'); 'email/verify','email/verify/*','email/verification-notification','two-factor-challenge',
// Compatibility until an independent perimeter is explicitly configured. 'user/confirm-password','user/confirmed-password-status','user/two-factor-*',
if ($request->is('login', 'logout', 'register', 'forgot-password', 'reset-password', 'reset-password/*', 'settings/security','settings/password','user/password','passkeys/*','user/passkeys/*','up')) return $next($request);
'email/verify', 'email/verify/*', 'email/verification-notification', 'two-factor-challenge', $actor=$request->user();
'user/confirm-password', 'user/confirmed-password-status', 'user/two-factor-*', if (!$actor) return $request->expectsJson()
'settings/security', 'settings/password', 'user/password', 'passkeys/*', 'user/passkeys/*', 'up')) { ? response()->json(['message'=>'Entre na conta autorizada para homologação.'],401)
return $next($request); : redirect()->guest(route('login'));
} abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)),config('portal.homologation_reviewers',[]),true),403,
$actor = $request->user(); 'Esta homologação está reservada às contas indicadas pela Tempo.');
if (! $actor) { $response=$next($request);$response->headers->set('Cache-Control','private, no-store');return $response;
return $request->expectsJson() }
? response()->json(['message' => 'Entre na conta autorizada para homologação.'], 401)
: redirect()->guest(route('login'));
}
abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)), config('portal.homologation_reviewers', []), true), 403,
'Esta homologação está reservada às contas indicadas pela Tempo.');
$response = $next($request);
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
} }
@@ -1,19 +1,15 @@
<?php <?php
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Services\DocumentManagementAccess;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class EnsureScopedEditorial class EnsureScopedEditorial
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$actor = $request->user(); $actor = $request->user();
abort_unless($actor !== null, 403); abort_unless($actor, 403);
$access = app(DocumentManagementAccess::class); $access = app(\App\Services\DocumentManagementAccess::class);
if ($access->full($actor)) { if ($access->full($actor)) {
return $next($request); return $next($request);
} }
@@ -24,7 +20,6 @@ class EnsureScopedEditorial
if ($request->route('id') !== null) { if ($request->route('id') !== null) {
$access->assertEditableResource($actor, (int) $request->route('id')); $access->assertEditableResource($actor, (int) $request->route('id'));
} }
return $next($request); return $next($request);
} }
} }
@@ -24,7 +24,7 @@ final class EnsureScopedPortalAdministration
'management/economic-references/lot-01', 'management/economic-references/lot-01/*', 'management/economic-references/lot-01', 'management/economic-references/lot-01/*',
'management/education', 'management/education/*', 'management/investment/references', 'management/investment/references/*', 'api/v1/investment/admin/references', 'api/v1/investment/admin/references/*', 'management/education', 'management/education/*', 'management/investment/references', 'management/investment/references/*', 'api/v1/investment/admin/references', 'api/v1/investment/admin/references/*',
'management/investment/zones', 'management/investment/zones/*', 'api/v2/investment/admin/zones', 'api/v2/investment/admin/zones/*', 'management/investment/zones', 'management/investment/zones/*', 'api/v2/investment/admin/zones', 'api/v2/investment/admin/zones/*',
'management/balcao/atribuicoes', 'management/balcao/atribuicoes/*', 'management/balcao/servicos', 'management/balcao/servicos/*', 'management/balcao/atribuicoes', 'management/balcao/atribuicoes/*', 'management/balcao/servicos',
'management/balcao/fontes') || ($request->isMethod('GET') && $path === 'management/balcao-install'); 'management/balcao/fontes') || ($request->isMethod('GET') && $path === 'management/balcao-install');
$fiscal = $request->is('api/v2/investment/admin/tax-rules', 'api/v2/investment/admin/tax-rules/*', $fiscal = $request->is('api/v2/investment/admin/tax-rules', 'api/v2/investment/admin/tax-rules/*',
'api/v2/investment/admin/tax-rule-sources', 'management/investment/tax-rules/*'); 'api/v2/investment/admin/tax-rule-sources', 'management/investment/tax-rules/*');
@@ -64,7 +64,6 @@ final class EnsureSimulationRecoveryCookie
false, false,
'lax', 'lax',
)); ));
return $response; return $response;
} }
+11 -19
View File
@@ -3,14 +3,10 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Services\ContentManagement;
use App\Services\FundingProgrammes;
use App\Services\InvestorPath; use App\Services\InvestorPath;
use App\Services\LegacyContent; use App\Services\LegacyContent;
use App\Services\OpportunityManagement; use App\Services\OpportunityManagement;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use App\Services\Procurement;
use App\Services\ScopedPortalAdministration;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@@ -52,19 +48,15 @@ class HandleInertiaRequests extends Middleware
*/ */
public function share(Request $request): array public function share(Request $request): array
{ {
$contentManagementAccess = app(ContentManagement::class)->access($request->user()); $contentManagementAccess=app(\App\Services\ContentManagement::class)->access($request->user());
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'administrationNavigation' => app(ContentManagement::class)->administrationNavigation($request->user(), $contentManagementAccess), 'procurementEnabled' => app(\App\Services\Procurement::class)->available(),
'dashboardNavigation' => app(ContentManagement::class)->dashboardNavigation($request->user(), $contentManagementAccess),
'procurementEnabled' => app(Procurement::class)->available(),
'fundingEnabled' => app(FundingProgrammes::class)->installed(),
'homologationReserved' => (bool) config('portal.homologation_only', false), 'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()), 'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null, 'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null,
'contentManagementAccess' => $contentManagementAccess, 'contentManagementAccess'=>$contentManagementAccess,
'portalOperator' => config('portal.operator_name'), 'portalOperator' => config('portal.operator_name'),
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null, 'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
'legacyContentEnabled' => app(LegacyContent::class)->available(), 'legacyContentEnabled' => app(LegacyContent::class)->available(),
@@ -81,14 +73,14 @@ class HandleInertiaRequests extends Middleware
'installationHref' => '/management/opportunity-management-install', 'installationHref' => '/management/opportunity-management-install',
] : null, ] : null,
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning'), 'uploaded_document_id' => fn () => $request->session()->get('uploaded_document_id')], 'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning'), 'uploaded_document_id' => fn () => $request->session()->get('uploaded_document_id')],
'scopedAdministration' => $request->user() && app(ScopedPortalAdministration::class)->administrative($request->user()) ? [ 'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [
['label' => 'Zonas e parques', 'href' => '/management/investment/zones'], ['label'=>'Zonas e parques','href'=>'/management/investment/zones'],
['label' => 'Referências económicas', 'href' => '/management/economic-references/lot-01'], ['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'],
['label' => 'Referências e formulários', 'href' => '/management/investment/references'], ['label'=>'Referências e formulários','href'=>'/management/investment/references'],
['label' => 'Educação e competências', 'href' => '/management/education'], ['label'=>'Educação e competências','href'=>'/management/education'],
['label' => 'Interesses atribuídos', 'href' => '/management/interests'], ['label'=>'Interesses atribuídos','href'=>'/management/interests'],
['label' => 'Atribuições de interesses', 'href' => '/management/interests/atribuicoes'], ['label'=>'Atribuições de interesses','href'=>'/management/interests/atribuicoes'],
['label' => 'Atribuições do Balcão', 'href' => '/management/balcao/atribuicoes'], ['label'=>'Atribuições do Balcão','href'=>'/management/balcao/atribuicoes'],
] : [], ] : [],
'auth' => [ 'auth' => [
'user' => $request->user(), 'user' => $request->user(),
+2 -6
View File
@@ -13,19 +13,15 @@ class PortalProfileSession
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (! $request->user()) {
return $next($request);
}
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
$available = $access->governanceAvailable(); $available = $access->governanceAvailable();
if ((config('portal.profile_access_enabled') || config('portal.profile_administration_enabled')) && ! $available) { if ($request->user() && (config('portal.profile_access_enabled') || config('portal.profile_administration_enabled')) && ! $available) {
$recovery = $request->is('management/profile-install', 'management/profile-install/bootstrap') $recovery = $request->is('management/profile-install', 'management/profile-install/bootstrap')
|| ($request->is('logout') && $request->isMethod('POST')); || ($request->is('logout') && $request->isMethod('POST'));
abort_if(! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && ! $recovery, 503, abort_if(! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && ! $recovery, 503,
'Autorizações indisponíveis: recuperar a consistência dos perfis antes de gravar. Pode terminar a sessão ou consultar o instalador.'); 'Autorizações indisponíveis: recuperar a consistência dos perfis antes de gravar. Pode terminar a sessão ou consultar o instalador.');
} }
if ($available) { if ($request->user() && $available) {
$revision = (int) DB::table('users')->where('id', $request->user()->id)->value('portal_access_revision'); $revision = (int) DB::table('users')->where('id', $request->user()->id)->value('portal_access_revision');
$bound = $request->session()->get('portal.access_revision'); $bound = $request->session()->get('portal.access_revision');
if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) { if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) {
@@ -23,14 +23,6 @@ class ProtectProfileOpportunity
} }
if ($request->user() && $id !== null && Schema::hasColumn('opportunities', 'owner_user_id')) { if ($request->user() && $id !== null && Schema::hasColumn('opportunities', 'owner_user_id')) {
$record = Opportunity::withTrashed()->find($id); $record = Opportunity::withTrashed()->find($id);
if ($record?->isPrivateOfferPreparation()) {
app(PortalProfileAccess::class)->authorize($request->user(), $request->isMethod('GET') ? 'view' : 'edit', $record);
if ($request->isMethod('GET') && $request->is('minhas-oportunidades/*/edit', 'minhas-oportunidades/*/view', 'management/opportunities/*/edit', 'management/opportunities/*/view')) {
return redirect('/minhas-oportunidades/ofertas/'.$record->id)->header('Cache-Control', 'private, no-store');
}
// Contact registration reuses the CRM master and its fresh-MFA gate.
abort_unless($request->isMethod('POST') && $request->is('minhas-oportunidades/*/contacts'), 403, 'Use a preparação privada da oferta.');
}
if ($record && $record->owner_user_id === null && ! $request->isMethod('GET')) { if ($record && $record->owner_user_id === null && ! $request->isMethod('GET')) {
abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 403, 'O papel v1 não contorna os controlos através de um registo legado.'); abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 403, 'O papel v1 não contorna os controlos através de um registo legado.');
} }
+1 -10
View File
@@ -2,8 +2,6 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use App\Services\TerritorialRegistry;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
@@ -26,16 +24,9 @@ class TerritorialRequest
} else { } else {
abort_unless($request->user()->can('manage-portal') && $request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403); abort_unless($request->user()->can('manage-portal') && $request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
$role = $request->is('*/transitions') ? match ($request->input('action')) { $role = $request->is('*/transitions') ? match ($request->input('action')) {
'in_review', 'approved', 'rejected' => 'reviewers', 'published', 'archived', 'withdrawn', 'revoked' => 'publishers', default => 'editors', 'approved', 'rejected' => 'reviewers', 'published', 'archived', 'revoked' => 'publishers', default => 'editors',
} : 'editors'; } : 'editors';
abort_unless(in_array(strtolower($request->user()->email), config('territorial.'.$role, []), true), 403, 'Função editorial não atribuída.'); abort_unless(in_array(strtolower($request->user()->email), config('territorial.'.$role, []), true), 403, 'Função editorial não atribuída.');
if ($request->is('api/v1/geo/territories*')) {
app(TerritorialRegistry::class)->ensureWriteMode($request->route('id'));
if (config('editorial_workflow.territorial_versions')) {
abort_unless(app(PortalProfileAccess::class)->mfa($request->user())
&& ! app(PortalProfileAccess::class)->has($request->user(), 'auditor'), 403, 'MFA válido necessário.');
}
}
$key = $request->header('Idempotency-Key', ''); $key = $request->header('Idempotency-Key', '');
abort_unless(Str::isUuid($key), 422, 'Idempotency-Key UUID obrigatório.'); abort_unless(Str::isUuid($key), 422, 'Idempotency-Key UUID obrigatório.');
$scope = $request->method().':'.$request->path(); $scope = $request->method().':'.$request->path();
@@ -13,22 +13,15 @@ class VerifiedOrSimpleParticipation
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if ($request->user()->hasVerifiedEmail()) { if ($request->user()->hasVerifiedEmail()) return $next($request);
return $next($request); if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) return $next($request);
}
if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) {
return $next($request);
}
$id = $request->route('id'); $id = $request->route('id');
$show = $request->isMethod('GET') && $request->is('investidor/manifestacoes/'.$id, 'api/v1/investment/submissions/'.$id); $show = $request->isMethod('GET') && $request->is('investidor/manifestacoes/'.$id, 'api/v1/investment/submissions/'.$id);
$message = $request->isMethod('POST') && $request->is('investidor/manifestacoes/'.$id.'/messages', 'api/v1/investment/submissions/'.$id.'/messages'); $message = $request->isMethod('POST') && $request->is('investidor/manifestacoes/'.$id.'/messages', 'api/v1/investment/submissions/'.$id.'/messages');
if ($id && ($show || $message)) { if ($id && ($show || $message)) {
$row = InvestmentSubmission::whereKey($id)->where('user_id', $request->user()->id)->firstOrFail(); $row = InvestmentSubmission::whereKey($id)->where('user_id', $request->user()->id)->firstOrFail();
if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) { if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) return $next($request);
return $next($request);
}
} }
return app(EnsureEmailIsVerified::class)->handle($request, $next); return app(EnsureEmailIsVerified::class)->handle($request, $next);
} }
} }
@@ -6,10 +6,8 @@ namespace App\Http\Requests;
use App\Models\LandAssistanceCase; use App\Models\LandAssistanceCase;
use App\Services\CountryCatalogue; use App\Services\CountryCatalogue;
use App\Services\InvestmentConsumerScope;
use App\Services\InvestmentGeography; use App\Services\InvestmentGeography;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Validator; use Illuminate\Validation\Validator;
@@ -21,14 +19,9 @@ class StoreLandAssistanceCaseRequest extends FormRequest
{ {
$payload = $this->validated('payload'); $payload = $this->validated('payload');
if (! is_array($payload)) { if (!is_array($payload)) return [];
return []; if (!empty($payload['investor_path_case_id'])) return app(\App\Services\InvestmentConsumerScope::class)->bind($this->user(), $payload['investor_path_case_id'], $payload, $this->routeIs('land-assistance.support'));
} return \Illuminate\Support\Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']);
if (! empty($payload['investor_path_case_id'])) {
return app(InvestmentConsumerScope::class)->bind($this->user(), $payload['investor_path_case_id'], $payload, $this->routeIs('land-assistance.support'));
}
return Arr::except($payload, ['context_opportunity', 'context_opportunity_snapshot']);
} }
public function authorize(): bool public function authorize(): bool
@@ -2,8 +2,6 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Services\ExistingBusinessClassification;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -25,15 +23,11 @@ class PublicBusinessOpportunityResource extends JsonResource
'amount_max' => data_get($this->resource, 'amount_max'), 'amount_max' => data_get($this->resource, 'amount_max'),
'available_percentage' => data_get($this->resource, 'available_percentage'), 'available_percentage' => data_get($this->resource, 'available_percentage'),
'published_at' => data_get($this->resource, 'published_at'), 'published_at' => data_get($this->resource, 'published_at'),
'distance_km' => $this->resource->getAttribute('distance_km') !== null ? round((float) $this->resource->getAttribute('distance_km'), 1) : null,
'location_recorded_at' => $this->resource->getAttribute('location_recorded_at') ? CarbonImmutable::parse($this->resource->getAttribute('location_recorded_at'))->toIso8601String() : null,
'location_source_reference' => $this->resource->getAttribute('location_source_reference'),
'location_source_date' => $this->resource->getAttribute('location_source_date'),
'sector' => $this->whenLoaded('sector', fn () => data_get($this->resource, 'sector')?->only(['name', 'slug'])), 'sector' => $this->whenLoaded('sector', fn () => data_get($this->resource, 'sector')?->only(['name', 'slug'])),
'province' => $this->resource->getAttribute('province'), 'province' => $this->resource->getAttribute('province'),
'district' => $this->resource->getAttribute('district'), 'district' => $this->resource->getAttribute('district'),
'locality' => $this->resource->getAttribute('locality'), 'locality' => $this->resource->getAttribute('locality'),
'multidimensional' => app(ExistingBusinessClassification::class)->publicRows($this->resource), 'multidimensional' => app(\App\Services\ExistingBusinessClassification::class)->publicRows($this->resource),
]; ];
} }
} }
-46
View File
@@ -1,46 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/** Existing email transport and operational allowlist; no confidential payload. */
final class NotifyPrivateOfferDecision implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public string $noticeId) {}
/** @return list<int> */
public function backoff(): array
{
return [60, 300, 900];
}
public function handle(): void
{
if (! config('portal.notifications_enabled')) {
return;
}
$notice = DB::table('portal_editorial_notifications')->where('id', $this->noticeId)->first();
$user = $notice ? User::query()->whereKey($notice->user_id)->first() : null;
$record = $notice ? Opportunity::query()->whereKey($notice->opportunity_id)->first() : null;
if (! $notice || ! $user || ! $record?->isPrivateOfferPreparation()
|| ! in_array($user->email, config('portal.notification_recipients', []), true)
|| ! app(PortalEditorialWorkflow::class)->visibleNotice($user, $notice)) {
return;
}
$body = 'Existe uma actualização na revisão privada da oferta '.$record->reference.'. Consulte a decisão e a versão na área protegida: '.url('/minhas-oportunidades/ofertas/'.$record->id);
Mail::raw($body, fn ($message) => $message->to($user->email)->subject('Actualização da oferta — '.$record->reference));
}
}
+15 -112
View File
@@ -2,9 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Services\Malware\DocumentScanAttempts;
use App\Services\Malware\MalwareScanner; use App\Services\Malware\MalwareScanner;
use App\Services\OpportunityTechnicalIncidents;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@@ -13,7 +11,6 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Throwable; use Throwable;
@@ -28,28 +25,7 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
public int $uniqueFor = 600; public int $uniqueFor = 600;
public ?string $executionId = null; public function __construct(public readonly string $documentId) {}
/** @var array<string,mixed>|null */
public ?array $opportunityContext = null;
/** @param array<string,mixed>|null $opportunityContext */
public function __construct(public readonly string $documentId, ?array $opportunityContext = null)
{
$this->executionId = (string) Str::uuid();
$this->opportunityContext = $opportunityContext;
}
/** Queue UUID also supports payloads serialized before executionId existed. */
public function runIdentifier(): string
{
if ($this->executionId !== null) {
return $this->executionId;
}
$queued = $this->job?->uuid();
return is_string($queued) && Str::isUuid($queued) ? $queued : ($this->executionId = (string) Str::uuid());
}
public function uniqueId(): string public function uniqueId(): string
{ {
@@ -75,20 +51,11 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
if ($row->malware_scan_status === 'infected') { if ($row->malware_scan_status === 'infected') {
return null; return null;
} }
$attempts = app(DocumentScanAttempts::class);
if ($attempts->completed($row->id, $this->runIdentifier())) {
return null;
}
if ($row->malware_scan_status === 'scanning' && $attempts->owns($row, $this->runIdentifier(), (int) $this->attempts())) {
return null;
}
$attempt = $attempts->start($row, $this->runIdentifier(), (int) $this->attempts());
DB::table('documents')->where('id', $this->documentId)->update([ DB::table('documents')->where('id', $this->documentId)->update([
'status' => 'quarantined', 'malware_scan_status' => 'scanning', 'malware_failure_reason' => null, 'status' => 'quarantined', 'malware_scan_status' => 'scanning', 'malware_failure_reason' => null,
'malware_scan_attempts' => $attempt, 'updated_at' => now(), 'malware_scan_attempts' => DB::raw('malware_scan_attempts + 1'), 'updated_at' => now(),
]); ]);
$row->malware_scan_attempts = $attempt;
return $row; return $row;
}); });
@@ -102,32 +69,22 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
$root = $disk->path(''); $root = $disk->path('');
$path = $disk->path($document->storage_key); $path = $disk->path($document->storage_key);
$temporary = null; $temporary = null;
$operationFailed = false;
try { try {
if (($document->storage_encryption ?? null) === 'laravel') { if (($document->storage_encryption ?? null) === 'laravel') {
$temporary = tempnam($root, 'scan-private-'); $temporary = tempnam($root, 'scan-private-');
if ($temporary === false) { if ($temporary === false) {
throw new \RuntimeException('Private scan workspace unavailable'); throw new \RuntimeException('Private scan workspace unavailable');
} }
if (! @chmod($temporary, 0600)) { chmod($temporary, 0600);
throw new \RuntimeException('Private scan workspace permissions unavailable'); file_put_contents($temporary, Crypt::decryptString($disk->get($document->storage_key)));
}
$plain = Crypt::decryptString($disk->get($document->storage_key));
if (file_put_contents($temporary, $plain) !== strlen($plain)) {
throw new \RuntimeException('Private scan workspace write incomplete');
}
unset($plain);
$path = $temporary; $path = $temporary;
} }
$beforeHash = $this->fileHash($path); $beforeHash = $this->fileHash($path);
if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) { if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) {
$applied = $this->record($document, [ $this->record($document, [
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'not_run', 'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'ClamAV',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null, 'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null,
], $startedAt, 'inconclusive'); ], $startedAt, 'inconclusive');
if ($applied) {
$this->notify('scan_inconclusive');
}
return; return;
} }
@@ -142,29 +99,10 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
} }
$result['file_sha256'] = $afterHash; $result['file_sha256'] = $afterHash;
$applied = $this->record($document, $result, $startedAt, $result['result']); $this->record($document, $result, $startedAt, $result['result']);
if (in_array($applied, ['inconclusive', 'failed'], true)) {
$this->notify($applied === 'failed' ? 'scan_worker_failed' : 'scan_inconclusive');
}
} catch (Throwable $error) {
$operationFailed = true;
throw $error;
} finally { } finally {
if (is_string($temporary) && is_file($temporary)) { if (is_string($temporary) && is_file($temporary)) {
try { unlink($temporary);
$removed = @unlink($temporary);
} catch (Throwable) {
$removed = false;
}
if (! $removed) {
try {
Log::warning('Private scan workspace cleanup requires verification.', ['reference' => $this->runIdentifier(), 'error_code' => 'scan_cleanup_unconfirmed']);
} catch (Throwable) {
}
if (! $operationFailed) {
$this->notify('scan_cleanup_unconfirmed');
}
}
} }
} }
} }
@@ -175,38 +113,11 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
if (! $document || $document->status === 'deleted' || $document->deleted_at !== null) { if (! $document || $document->status === 'deleted' || $document->deleted_at !== null) {
return; return;
} }
if ($document->malware_scan_status === 'pending') {
// A failure before claiming the row cannot overwrite another scan result.
$this->notify('scan_worker_failed');
return; $this->record($document, [
} 'result' => 'failed', 'reason_code' => 'scanner_unavailable_after_retries', 'engine' => 'ClamAV',
if ($document->malware_scan_status !== 'scanning' || ! app(DocumentScanAttempts::class)->owns($document, $this->runIdentifier(), (int) $this->attempts())) {
return;
}
$descriptor = app(DocumentScanAttempts::class)->descriptor($document, $this->runIdentifier(), (int) $this->attempts());
if ($descriptor === null) {
$this->notify('scan_worker_failed');
return;
}
$document->sha256 = $descriptor['sha256'];
$document->size_bytes = $descriptor['size_bytes'];
$applied = $this->record($document, [
'result' => 'failed', 'reason_code' => 'scan_failed_after_retries', 'engine' => 'unknown',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => null, 'detail' => null, 'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => null, 'detail' => null,
], now(), 'failed'); ], now(), 'failed');
if ($applied) {
$this->notify('scan_worker_failed');
}
}
private function notify(string $errorCode): void
{
if ($this->opportunityContext !== null) {
app(OpportunityTechnicalIncidents::class)->recordScan($this->opportunityContext, $this->runIdentifier(), $errorCode);
}
} }
private function fileHash(string $path): ?string private function fileHash(string $path): ?string
@@ -219,17 +130,12 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
} }
/** @param array<string,mixed> $result */ /** @param array<string,mixed> $result */
private function record(\stdClass $document, array $result, \DateTimeInterface $startedAt, string $state): ?string private function record(\stdClass $document, array $result, \DateTimeInterface $startedAt, string $state): void
{ {
return DB::transaction(function () use ($document, $result, $startedAt, $state): ?string { DB::transaction(function () use ($document, $result, $startedAt, $state): void {
$locked = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first(); $locked = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first();
if (! $locked || $locked->status === 'deleted' || $locked->deleted_at !== null) { if (! $locked || $locked->status === 'deleted' || $locked->deleted_at !== null) {
return null; return;
}
if ($locked->malware_scan_status !== 'scanning' || ! app(DocumentScanAttempts::class)->owns($locked, $this->runIdentifier(), (int) $this->attempts(), (int) $document->malware_scan_attempts)) {
app(DocumentScanAttempts::class)->discarded($locked, $this->runIdentifier(), (int) $this->attempts(), (int) $document->malware_scan_attempts);
return null;
} }
$sameBytes = hash_equals(trim($document->sha256), trim((string) $locked->sha256)); $sameBytes = hash_equals(trim($document->sha256), trim((string) $locked->sha256));
@@ -254,16 +160,13 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
'updated_at' => $finishedAt, 'updated_at' => $finishedAt,
]); ]);
DB::table('document_malware_scans')->insert([ DB::table('document_malware_scans')->insert([
'id' => $scanId, 'document_id' => $this->documentId, 'file_sha256' => trim($document->sha256), 'id' => $scanId, 'document_id' => $this->documentId, 'file_sha256' => trim($locked->sha256),
'result' => $finalState, 'reason_code' => $reason, 'result' => $finalState, 'reason_code' => $reason,
'engine' => $result['engine'] ?? 'ClamAV', 'engine_version' => $result['engine_version'] ?? 'unknown', 'engine' => $result['engine'] ?? 'ClamAV', 'engine_version' => $result['engine_version'] ?? 'unknown',
'signatures_at' => $result['signatures_at'] ?? null, 'started_at' => $startedAt, 'signatures_at' => $result['signatures_at'] ?? null, 'started_at' => $startedAt,
'finished_at' => $finishedAt, 'duration_ms' => $result['duration_ms'] ?? null, 'finished_at' => $finishedAt, 'duration_ms' => $result['duration_ms'] ?? null,
'file_size_bytes' => $document->size_bytes ?? null, 'created_at' => $finishedAt, 'updated_at' => $finishedAt, 'file_size_bytes' => $locked->size_bytes ?? null, 'created_at' => $finishedAt, 'updated_at' => $finishedAt,
]); ]);
app(DocumentScanAttempts::class)->finish($document, $this->runIdentifier(), (int) $this->attempts(), $scanId, $state === 'failed');
return $finalState;
}); });
} }
} }
-6
View File
@@ -2,10 +2,4 @@
namespace App\Models; namespace App\Models;
/**
* @property string|null $external_id
* @property string|null $source_system
* @property string $full_name
* @property string|null $phone
*/
class Contact extends CrmRecord {} class Contact extends CrmRecord {}
-21
View File
@@ -3,8 +3,6 @@
namespace App\Models; namespace App\Models;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Schema;
/** /**
* @property string|null $lead_id * @property string|null $lead_id
@@ -15,23 +13,4 @@ class CrmTask extends CrmRecord
protected $table = 'tasks'; protected $table = 'tasks';
protected $casts = ['due_at' => 'immutable_datetime']; protected $casts = ['due_at' => 'immutable_datetime'];
/**
* @param Builder<CrmTask> $query
* @return Builder<CrmTask>
*/
public function scopeAvailableForWork(Builder $query): Builder
{
$query->whereNull('tasks.deleted_at')->whereIn('tasks.status', ['open', 'in_progress']);
if (Schema::hasColumn('tasks', 'desk_case_id')) {
if (Schema::hasColumn('desk_cases', 'closed_at')) {
$query->where(fn ($q) => $q->whereNull('tasks.desk_case_id')->orWhereExists(fn ($desk) => $desk->selectRaw('1')->from('desk_cases')
->whereColumn('desk_cases.id', 'tasks.desk_case_id')->whereNull('desk_cases.closed_at')));
} else {
$query->whereNull('tasks.desk_case_id');
}
}
return $query;
}
} }
-1
View File
@@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Model;
* @property string $validation_status * @property string $validation_status
* @property string $valid_from * @property string $valid_from
* @property string|null $valid_until * @property string|null $valid_until
* @property array<string, mixed>|null $fiscal_acceptance
*/ */
class InvestmentLegalSource extends Model class InvestmentLegalSource extends Model
{ {
+1 -1
View File
@@ -24,7 +24,7 @@ class InvestmentZone extends DomainRecord
{ {
protected $hidden = ['editorial_draft', 'operational_details']; protected $hidden = ['editorial_draft', 'operational_details'];
protected $casts = ['operational_details' => 'array', 'version_no' => 'integer', 'published_at' => 'datetime', 'aliases' => 'array', 'activities' => 'array', 'resources' => 'array', 'historical_territory' => 'array', 'area_components' => 'array', 'source_evidence' => 'array', 'area_value' => 'decimal:4', 'data_as_of' => 'date', 'sources_consulted_at' => 'date']; protected $casts = ['operational_details' => 'array', 'operational_details' => 'array', 'version_no' => 'integer', 'published_at' => 'datetime', 'aliases' => 'array', 'activities' => 'array', 'resources' => 'array', 'historical_territory' => 'array', 'area_components' => 'array', 'source_evidence' => 'array', 'area_value' => 'decimal:4', 'data_as_of' => 'date', 'sources_consulted_at' => 'date'];
/** @return BelongsTo<GeoLocation, $this> */ /** @return BelongsTo<GeoLocation, $this> */
public function primaryTerritory(): BelongsTo public function primaryTerritory(): BelongsTo
+3 -29
View File
@@ -2,9 +2,6 @@
namespace App\Models; namespace App\Models;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityPublicText;
use App\Services\ProactiveInvestment;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -30,23 +27,6 @@ class Opportunity extends Model
{ {
use SoftDeletes; use SoftDeletes;
public function isPrivateOfferPreparation(): bool
{
return isset($this->workflow_context['private_offer_preparation']);
}
protected static function booted(): void
{
static::saving(function (self $record) {
$original = $record->getRawOriginal('workflow_context');
$wasPrivate = is_string($original) && isset(json_decode($original, true)['private_offer_preparation']);
abort_if($wasPrivate && ! $record->isPrivateOfferPreparation(), 403, 'A preparação privada não pode ser convertida neste percurso.');
if ($record->isPrivateOfferPreparation()) {
abort_unless($record->status === 'draft' && $record->published_at === null, 403, 'A publicação de Ofertas não está disponível nesta fase.');
}
});
}
protected $hidden = ['management_draft', 'management_creation_key', 'management_version', 'first_submitted_at', 'management_closed_once', 'owner_user_id', 'organization_id', 'workflow_context']; protected $hidden = ['management_draft', 'management_creation_key', 'management_version', 'first_submitted_at', 'management_closed_once', 'owner_user_id', 'organization_id', 'workflow_context'];
protected $appends = ['maturity_catalogue_version']; protected $appends = ['maturity_catalogue_version'];
@@ -54,13 +34,8 @@ class Opportunity extends Model
public function toArray(): array public function toArray(): array
{ {
$data = parent::toArray(); $data = parent::toArray();
if (isset($data['description'])) { if (isset($data['description'])) $data['description'] = \App\Services\OpportunityPublicText::description($this, $data['description']);
$data['description'] = OpportunityPublicText::description($this, $data['description']); if ($this->exists && ($proactive = app(\App\Services\ProactiveInvestment::class)->publicData($this))) $data['proactive'] = $proactive;
}
if ($this->exists && ($proactive = app(ProactiveInvestment::class)->publicData($this))) {
$data['proactive'] = $proactive;
}
return $data; return $data;
} }
@@ -69,7 +44,7 @@ class Opportunity extends Model
// Only published metadata is public; never expose the current private draft. // Only published metadata is public; never expose the current private draft.
$version = data_get($this->workflow_context, 'publication.maturity.catalogue_version'); $version = data_get($this->workflow_context, 'publication.maturity.catalogue_version');
return in_array($version, [OpportunityMaturity::VERSION, OpportunityMaturity::LEGACY], true) return in_array($version, [\App\Services\OpportunityMaturity::VERSION, \App\Services\OpportunityMaturity::LEGACY], true)
? $version : null; ? $version : null;
} }
@@ -132,7 +107,6 @@ class Opportunity extends Model
public function scopePublished(Builder $query): Builder public function scopePublished(Builder $query): Builder
{ {
if (Schema::hasColumn('opportunities', 'workflow_context')) { if (Schema::hasColumn('opportunities', 'workflow_context')) {
$query->whereNull('workflow_context->private_offer_preparation');
$query->where(fn ($q) => $q->whereNull('owner_user_id')->orWhere('workflow_context->publication->visibility', 'public')); $query->where(fn ($q) => $q->whereNull('owner_user_id')->orWhere('workflow_context->publication->visibility', 'public'));
} }
+4 -15
View File
@@ -2,15 +2,10 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property list<array<string, mixed>> $specializations
* @property string $url
*/
class Sector extends Model class Sector extends Model
{ {
use SoftDeletes; use SoftDeletes;
@@ -25,24 +20,18 @@ class Sector extends Model
'published_at' => 'datetime', 'published_at' => 'datetime',
]; ];
/** Presentation only: historical masters and foreign keys remain untouched. /** Presentation only: historical masters and foreign keys remain untouched. */
* @param Builder<Sector> $query public function scopeChoices($query)
* @return Builder<Sector>
*/
public function scopeChoices(Builder $query): Builder
{ {
return $query->where('is_active', true)->where('slug', '!=', 'energia'); return $query->where('is_active', true)->where('slug', '!=', 'energia');
} }
/** @param Builder<Sector> $query public function scopeCatalog($query)
* @return Builder<Sector>
*/
public function scopeCatalog(Builder $query): Builder
{ {
return $query->choices()->whereNotNull('published_at')->where('slug', '!=', 'fintech'); return $query->choices()->whereNotNull('published_at')->where('slug', '!=', 'fintech');
} }
public function getNameAttribute(?string $value): ?string public function getNameAttribute($value)
{ {
return ($this->attributes['slug'] ?? null) === 'fintech' ? 'Mercado Financeiro — Fintech' : $value; return ($this->attributes['slug'] ?? null) === 'fintech' ? 'Mercado Financeiro — Fintech' : $value;
} }
-3
View File
@@ -11,9 +11,6 @@ use Illuminate\Support\Carbon;
* @property string $component * @property string $component
* @property string $scope_key * @property string $scope_key
* @property string $status * @property string $status
* @property array<string, mixed> $rule_definition
* @property array<string, mixed> $parameters
* @property array<string, mixed> $rounding_policy
* @property int $author_id * @property int $author_id
* @property int|null $reviewer_id * @property int|null $reviewer_id
* @property int|null $approver_id * @property int|null $approver_id
+7 -16
View File
@@ -4,7 +4,6 @@ namespace App\Policies;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\User; use App\Models\User;
use App\Services\ExistingBusinessProfileCategory;
use App\Services\PortalProfileAccess; use App\Services\PortalProfileAccess;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -88,13 +87,11 @@ class ExistingBusinessPolicy
return $this->designatedAny($user); return $this->designatedAny($user);
} }
/** Filter before projecting or decrypting private fields in the review queue. /** Filter before projecting or decrypting private fields in the review queue. */
* @param Builder<ExistingBusiness> $query
*/
public function scopeReviewQueue(Builder $query, User $user): void public function scopeReviewQueue(Builder $query, User $user): void
{ {
if (app(PortalProfileAccess::class)->available()) { if (app(PortalProfileAccess::class)->available()) {
$query->whereIn('modality', array_keys(ExistingBusinessProfileCategory::MAP)); $query->whereIn('modality',array_keys(\App\Services\ExistingBusinessProfileCategory::MAP));
$ids = (clone $query)->get()->filter(fn (ExistingBusiness $business) => $this->view($user, $business))->modelKeys(); $ids = (clone $query)->get()->filter(fn (ExistingBusiness $business) => $this->view($user, $business))->modelKeys();
$query->whereKey($ids); $query->whereKey($ids);
} }
@@ -122,18 +119,13 @@ class ExistingBusinessPolicy
return false; return false;
} }
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
if ($access->has($user, 'auditor')) { if ($access->has($user, 'auditor')) { return false; }
return false;
}
if (! $access->available()) { if (! $access->available()) {
return $access->legacyWriteAllowed($user) && (bool) $user->two_factor_confirmed_at && (bool) $user->two_factor_secret return $access->legacyWriteAllowed($user) && (bool) $user->two_factor_confirmed_at && (bool) $user->two_factor_secret
&& in_array(strtolower($user->email), config('existing-business.reviewers', []), true); && in_array(strtolower($user->email), config('existing-business.reviewers', []), true);
} }
if ($business->modality !== 'company_sale') { if ($business->modality !== 'company_sale') { return false; }
return false;
}
$responsibility = $dimension === 'regulation' ? 'legal_reviewer' : 'technical_reviewer'; $responsibility = $dimension === 'regulation' ? 'legal_reviewer' : 'technical_reviewer';
return collect($access->grants($user, 'validator'))->contains(fn ($g) => $g->organization_id === null return collect($access->grants($user, 'validator'))->contains(fn ($g) => $g->organization_id === null
&& in_array('business_sale', json_decode($g->opportunity_types, true), true) && in_array('business_sale', json_decode($g->opportunity_types, true), true)
&& in_array($responsibility, json_decode($g->responsibilities, true), true)); && in_array($responsibility, json_decode($g->responsibilities, true), true));
@@ -145,7 +137,6 @@ class ExistingBusinessPolicy
|| app(PortalProfileAccess::class)->has($user, 'auditor') || ! $this->designated($user, 'publishers', $business)) { || app(PortalProfileAccess::class)->has($user, 'auditor') || ! $this->designated($user, 'publishers', $business)) {
return false; return false;
} }
return ! DB::table('business_workflow_transitions')->where('existing_business_id', $business->id) return ! DB::table('business_workflow_transitions')->where('existing_business_id', $business->id)
->whereIn('to_status', ['approval_l2', 'approved'])->where('actor_id', $user->id)->exists(); ->whereIn('to_status', ['approval_l2', 'approved'])->where('actor_id', $user->id)->exists();
} }
@@ -165,8 +156,8 @@ class ExistingBusinessPolicy
{ {
$access = app(PortalProfileAccess::class); $access = app(PortalProfileAccess::class);
if ($access->available()) { if ($access->available()) {
$category = $business ? ExistingBusinessProfileCategory::category($business) : null; $category=$business ? \App\Services\ExistingBusinessProfileCategory::category($business) : null;
if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user, 'auditor')) { if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user,'auditor')) {
return false; return false;
} }
$role = $designation === 'publishers' ? 'publisher' : 'validator'; $role = $designation === 'publishers' ? 'publisher' : 'validator';
@@ -177,7 +168,7 @@ class ExistingBusinessPolicy
$organizationMatches = $grant->organization_id === null $organizationMatches = $grant->organization_id === null
|| ($business !== null && $grant->organization_id === $business->organization_id); || ($business !== null && $grant->organization_id === $business->organization_id);
if ($organizationMatches if ($organizationMatches
&& ($category !== null ? in_array($category, json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR), true) : array_intersect(array_values(ExistingBusinessProfileCategory::MAP), json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR)) !== [])) { && ($category !== null ? in_array($category,json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR),true) : array_intersect(array_values(\App\Services\ExistingBusinessProfileCategory::MAP),json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR)) !== [])) {
return true; return true;
} }
} }
+2 -8
View File
@@ -22,8 +22,6 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\LogoutResponse;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided; use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -47,12 +45,12 @@ class AppServiceProvider extends ServiceProvider
// Fortify calls this response AFTER invalidating/regenerating the server session. // Fortify calls this response AFTER invalidating/regenerating the server session.
// Clear the client's encryption key too, so Back must reauthorize private pages. // Clear the client's encryption key too, so Back must reauthorize private pages.
$this->app->bind(LogoutResponse::class, function () { $this->app->bind(\Laravel\Fortify\Contracts\LogoutResponse::class, function () {
return new class extends \Laravel\Fortify\Http\Responses\LogoutResponse return new class extends \Laravel\Fortify\Http\Responses\LogoutResponse
{ {
public function toResponse($request) public function toResponse($request)
{ {
Inertia::clearHistory(); \Inertia\Inertia::clearHistory();
return parent::toResponse($request); return parent::toResponse($request);
} }
@@ -79,10 +77,6 @@ class AppServiceProvider extends ServiceProvider
Cache::forget('portal_home_en'); Cache::forget('portal_home_en');
Cache::forget('portal_home_v2_pt'); Cache::forget('portal_home_v2_pt');
Cache::forget('portal_home_v2_en'); Cache::forget('portal_home_v2_en');
Cache::forget('portal_home_sector_organization_v1_pt');
Cache::forget('portal_home_sector_organization_v1_en');
Cache::forget('portal_home_territorial_v2_pt');
Cache::forget('portal_home_territorial_v2_en');
}; };
$model::saved($invalidate); $model::saved($invalidate);
$model::deleted($invalidate); $model::deleted($invalidate);
+2 -3
View File
@@ -69,8 +69,7 @@ final class AssistiveRules
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereNull('owner_user_id')->orderBy('id')->limit(20)->get(['id']) as $lead) { foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereNull('owner_user_id')->orderBy('id')->limit(20)->get(['id']) as $lead) {
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_owner', 'source_url' => '/management/crm/leads/'.$lead->id]; $alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_owner', 'source_url' => '/management/crm/leads/'.$lead->id];
} }
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['new', 'qualified']) foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['new', 'qualified'])->whereDoesntHave('tasks', fn ($q) => $q->whereIn('status', ['open', 'in_progress']))
->whereNotExists(CrmTask::availableForWork()->whereColumn('tasks.lead_id', 'leads.id')->selectRaw('1')->toBase())
->orderBy('id')->limit(20)->get(['id']) as $lead) { ->orderBy('id')->limit(20)->get(['id']) as $lead) {
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_next_action', 'source_url' => '/management/crm/leads/'.$lead->id]; $alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_next_action', 'source_url' => '/management/crm/leads/'.$lead->id];
} }
@@ -92,7 +91,7 @@ final class AssistiveRules
/** @return list<array<string, mixed>> */ /** @return list<array<string, mixed>> */
public function sla(): array public function sla(): array
{ {
return array_values(CrmTask::whereIn('data_classification', ['public', 'internal'])->availableForWork()->where('due_at', '<', now())->orderBy('due_at')->limit(50) return array_values(CrmTask::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())->orderBy('due_at')->limit(50)
->get()->map(fn ($task) => [ ->get()->map(fn ($task) => [
'record_type' => 'task', 'record_id' => $task->id, 'rule' => 'overdue_open_task', 'record_type' => 'task', 'record_id' => $task->id, 'rule' => 'overdue_open_task',
'due_at' => $task->getAttribute('due_at')?->toIso8601String(), 'lead_id' => $task->lead_id, 'due_at' => $task->getAttribute('due_at')?->toIso8601String(), 'lead_id' => $task->lead_id,

Some files were not shown because too many files have changed in this diff Show More