Compare commits

..
Author SHA1 Message Date
nala e1f505f7fd Record served IVA functionality and team review limits 2026-10-21 14:43:23 +02:00
1873 changed files with 11378 additions and 464584 deletions
-16
View File
@@ -1,16 +0,0 @@
.git
.idea
.agents
.codex
node_modules
vendor
public/build
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
tests
Doc
docs
*.zip
.phpunit.result.cache
-5
View File
@@ -119,8 +119,3 @@ INVESTMOZ_TAX_RULE_APPROVERS=
INVESTMOZ_TAX_RULE_PUBLISHERS=
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=
-3
View File
@@ -12,6 +12,3 @@ database/data/economic-references-lot-01.json text eol=crlf
CHANGELOG.md export-ignore
README.md export-ignore
.github/workflows/browser-tests.yml export-ignore
# Preserve the original archived BM HTML bytes and whitespace.
docs/investmoz/simulations/source-archive/BM-Mercado-Monetario-MAIBOR-20261025.html -text -diff
-42
View File
@@ -1,30 +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
## 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
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.
Apresentar apenas o necessário ao investimento em preparação, com entrada por ideia, oportunidade, sector ou localização, reutilizando informação válida e autorizada da oportunidade, perfil, organização, dossier e fontes anteriores. As etapas são adaptáveis, não seis formulários obrigatórios. Não sobrescrever edições deliberadas; explicar divergências e impactos antes da confirmação. Conservar escolhas ao avançar, regressar, actualizar e autenticar-se. Seleccionar opções únicas e pedir apenas dados em falta ou desactualizados.
Ocultar campos e acções sem aplicação, conservando explicação breve e histórico; desconhecido não significa não aplicável. Na selecção múltipla, partilhar dados comuns sem perder os requisitos específicos de cada oportunidade. Consulta pública livre; autenticação no momento de manifestar interesse ou iniciar operação privada. Relatórios e exportações devem reflectir o contexto e as etapas efectivamente aplicáveis. O utilizador deve perceber onde está, o que falta e a próxima acção. Aperfeiçoar os componentes existentes por incrementos e registar cobertura na matriz existente; esta instrução não declara concluída a sua implementação transversal.
## Regras permanentes do InvesteMoz — decisão da Tempo, 12/10/2026
1. O portal InvesteMoz é propriedade da Tempo, Lda.
@@ -64,20 +39,3 @@ Referenciar estas orientações nos requisitos correspondentes da matriz existen
- Preservar o ficheiro original, a proveniência, o identificador exacto, a edição/data, o hash e o histórico. Não marcar autenticidade, vigência, direitos ou scan como confirmados sem evidência.
- Para fontes fiscais, inventariar também as fontes não analisadas e os diplomas/artigos sem correspondência documental. Parâmetros ausentes ou ambiguidades permanecem indeterminados; não usar zero nem a taxa mais recente como substituto.
- Antes de aplicar mudanças em dados activos, confirmar backup/restauro e a acção autenticada que continua necessária. Testes e migrações devem usar exclusivamente uma base QA isolada com guarda contra a base activa.
## Execução das fontes e decisões — orientação permanente da Tempo, 22/10/2026
A legislação, os formulários e os procedimentos oficiais disponibilizados são fontes válidas para implementar os requisitos. As decisões e procedimentos da Tempo orientam a sua utilização no portal. Traduzir essas fontes em campos, regras, documentos, cálculos e percursos executáveis com rastreabilidade, sem exigir nova validação jurídica genérica ou criar aprovadores, restrições ou condições por interpretação do Codex.
Uma divergência concreta deve identificar os trechos, períodos e comportamento afectados; prosseguir com os restantes requisitos. Uma decisão de implementação em falta deve ser apresentada especificamente à Tempo, sem a substituir por regra inventada. Rever e remover bloqueios anteriores sem fundamento numa fonte aplicável ou decisão expressa da Tempo, preservando dados e histórico. A rastreabilidade explica a execução e não constitui uma autoridade adicional. Mantêm-se os limites autorizados de QA e das alterações no activo.
## Preservação do concluído — pedido expresso da Tempo, 23/10/2026
Antes de avançar com novas rondas de testes/validações, inventariar na matriz e no manual existentes as funcionalidades concluídas e as funcionalidades desaparecidas, e repor as perdas confirmadas. Preservar as aprovações, dados, originais, relações, versões, histórico e acessos já concluídos. Não transformar uma entrega anterior em funcionalidade nova nem exigir novamente a decisão organizacional já registada.
Comparar código, schema, dados, configuração e permissões entre o último estado demonstrado e o destino. Distinguir eliminação, perda de visibilidade, diferença de ambiente, pendência real e estado documental desactualizado; uma página vazia ou a falta de nova prova não apagam a aceitação anterior. Reutilizar as provas aceites e conferir apenas o delta da reposição. Cada entrega seguinte deve explicar qualquer redução do âmbito previamente disponível, em vez de a aceitar como consequência normal da implementação.
Transporte privado não equivale à revogação de uma divulgação anterior aprovada. Reconciliar a decisão anterior de forma delimitada e auditada, sem publicar automaticamente conteúdos novos, reviver exemplares arquivados ou sobrescrever edições posteriores. Mantêm-se backup/restauro, autenticação e os limites concretos de cada tarefa.
Escolha técnica de verificação: comparar dados protegidos por segurança por linha (RLS) com identidades e contexto de acesso equivalentes. Uma leitura da aplicação sem actor pode ocultar registos que o utilizador do backup consegue ler; confirmar essa diferença antes de declarar perda ou propor reposição. Esta orientação não altera permissões do portal nem autoriza contornar RLS numa operação da equipa.
File diff suppressed because one or more lines are too long
Binary file not shown.
-342
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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;
}
}
+4 -9
View File
@@ -4,7 +4,6 @@ namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -54,9 +53,7 @@ class ImportDocumentBatch extends Command
];
private const TRIO_MANIFEST_SHA256 = '9210ad7e1bea557349c440fd8295b321a10c2ddd34cc170f3dad18b5517751f8';
private const TRIO_REUSED_IMAGE_SHA256 = '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa';
private const TRIO_HASHES = [
'legacy-tempo-post-173209-20260623' => '926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941',
'legacy-tempo-post-173218-20260616' => '43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5',
@@ -279,9 +276,7 @@ class ImportDocumentBatch extends Command
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.
* @param array<array-key, mixed> $manifest
*/
/** The extra CLI path is a named, byte-bound pilot; it is not a general active import gate. */
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);
@@ -315,7 +310,7 @@ class ImportDocumentBatch extends Command
->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']);
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.');
}
}
@@ -349,8 +344,8 @@ class ImportDocumentBatch extends Command
|| ! str_ends_with($proof['restore_target']['database'] ?? '', '_test') || (int) ($proof['restore_target']['port'] ?? 0) !== 55441
|| $verified === false || $verified > time() || $verified < time() - 86400
|| ! is_file($proof['dump_path'] ?? '') || ! is_file($proof['files_path'] ?? '')
|| ! hash_equals($proof['sha256'] ?? '', CheckedFile::sha256($proof['dump_path']))
|| ! hash_equals($proof['files_sha256'] ?? '', CheckedFile::sha256($proof['files_path']))) {
|| ! hash_equals($proof['sha256'] ?? '', hash_file('sha256', $proof['dump_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.');
}
}
@@ -7,7 +7,6 @@ use App\Models\User;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\CheckedFile;
use App\Support\IsolatedQaDatabase;
use Illuminate\Console\Command;
@@ -25,8 +24,8 @@ class RegisterFiscalQaDraft extends Command
$source = InvestmentLegalSource::findOrFail($this->argument('source'));
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).');
$payload = json_decode(CheckedFile::read(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'));
$payload = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$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.');
$payload['legal_source_id'] = (string) $source->id;
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\Models\Opportunity;
use App\Services\InvestmentGeography;
use App\Services\SectorOrganization;
use App\Models\Sector;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -21,9 +20,8 @@ class HomeController extends Controller
$locale = $validated['locale'] ?? 'pt';
$data = Cache::remember("portal_home_territorial_v2_{$locale}", now()->addMinutes(10), function () {
$sectors = SectorOrganization::catalog();
$statistics = app(InvestmentGeography::class)->opportunityStatistics();
$data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () {
$sectors = \App\Services\SectorOrganization::catalog();
$featured = Opportunity::with('sector:id,name,slug')
->published()->where('is_featured', true)
@@ -50,9 +48,9 @@ class HomeController extends Controller
'url' => "/oportunidades/{$o->slug}",
])->values()->all(),
'stats' => [
'opportunities' => $statistics['opportunities'],
'opportunities' => Opportunity::published()->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\Models\ExistingBusiness;
use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Validator;
@@ -28,7 +26,7 @@ class InterestRequestController extends Controller
$validated = [...$request->validated(), '_actor_id' => $request->user()->id];
$key = $request->header('Idempotency-Key');
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 {
$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,
'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'],
'country' => $validated['country'], 'existing_business_id' => $business?->id,
'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,
'action' => 'submitted', 'version' => 1, 'created_at' => now(),
]);
$interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save();
return $interest;
});
@@ -102,8 +99,7 @@ class InterestRequestController extends Controller
'message' => 'Manifestação de interesse registada com sucesso.',
'data' => [
'reference' => $interestRequest->reference,
// The POST acknowledgement is stable on replay; current workflow state is in tracking.
'status' => InterestStatus::Received,
'status' => $interestRequest->status,
'tracking_url' => '/investidor/manifestacoes',
],
], 201);
@@ -4,11 +4,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
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\Request;
@@ -20,11 +15,6 @@ class OpportunityController extends Controller
'q' => ['nullable', 'string', 'max:120'],
'sector' => ['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'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
@@ -43,30 +33,36 @@ class OpportunityController extends Controller
}
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'])) {
$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 (array_filter($territory, fn ($value) => $value !== null && $value !== '') !== []) {
// 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['province'])) {
$query->where('province', $validated['province']);
}
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')
->orderByDesc('published_at')
->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
@@ -3,10 +3,7 @@
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\EconomicReferenceCatalog;
use App\Services\SectorOrganization;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -47,9 +44,7 @@ class ReferenceCatalogController extends Controller
$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');
if ($cae && ! app(EconomicReferenceCatalog::class)->available()) {
$versionQuery->whereRaw('false');
}
if ($cae && ! app(\App\Services\EconomicReferenceCatalog::class)->available()) { $versionQuery->whereRaw('false'); }
if (isset($data['version'])) {
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
} else {
@@ -87,7 +82,7 @@ class ReferenceCatalogController extends Controller
$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();
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']);
}
@@ -174,17 +169,15 @@ class ReferenceCatalogController extends Controller
->where('valid_from', '<=', 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']);
if (empty($data['version'])) {
$query->where('id', '!=', SectorOrganization::LEGACY_ACTIVITY_ID);
}
if (empty($data['version'])) $query->where('id', '!=', \App\Services\SectorOrganization::LEGACY_ACTIVITY_ID);
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) {
$query->where('parent_id', $data['parent_id']);
} else {
$query->whereNull('parent_id');
}
if (! empty($data['sector_id'])) {
$sector = Sector::whereKey($data['sector_id'])->first();
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
$sector = \App\Models\Sector::find($data['sector_id']);
$query->whereIn('sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if (! empty($data['q'])) {
$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\Models\Sector;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
class SectorController extends Controller
{
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,
'shortDescription' => $s->short_description, 'description' => $s->description,
'icon' => $s->icon, 'opportunitiesCount' => $s->opportunities_count,
@@ -26,8 +25,7 @@ class SectorController extends Controller
$sector = Sector::with(['opportunities' => fn ($q) => $q->published()])
->where('slug', $slug)->firstOrFail();
$sector->setRelation('opportunities', SectorOrganization::opportunities($sector)->get());
return response()->json(['success' => true, 'data' => $sector, 'canonical_url' => SectorOrganization::href($slug), 'specialization_of' => $slug === 'fintech' ? 'mercado-financeiro' : null]);
$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]);
}
}
@@ -1,79 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Jobs\ScanDocumentForMalware;
use App\Services\SimulationFormDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use stdClass;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class SimulationOfferDocumentController extends Controller
{
public function store(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
// Same private document infrastructure and technical file limit as investment attachments.
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,image/png,image/jpeg', 'max:10240']]);
$file = $request->file('file');
$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();
if ($existing) {
return response()->json(['data' => $this->metadata($id, $existing)], 200)->header('Cache-Control', 'private, no-store');
}
$documentId = (string) Str::uuid();
$key = $file->storeAs('simulation-offers/'.$id, $documentId, 'local');
abort_unless(is_string($key), 500);
try {
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'simulation-fiscal-offer', 'external_id' => $id,
'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']);
} catch (\Throwable $e) {
Storage::disk('local')->delete($key);
throw $e;
}
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');
}
public function index(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$drafts->find($id, $request);
$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');
}
public function download(Request $request, string $id, string $document, SimulationFormDrafts $drafts): BinaryFileResponse
{
abort_unless(Str::isUuid($id) && Str::isUuid($document), 404);
$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();
abort_unless($doc !== null, 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.');
$root = realpath(Storage::disk('local')->path('simulation-offers/'.$id));
$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);
$hash = hash_file('sha256', $path);
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 array{id: string, draftId: string, name: string, sha256: string, scan: string, url: string} */
private function metadata(string $id, stdClass $doc): array
{
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];
}
}
@@ -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, ['approved', 'withdrawn'], true) ? 'required|string|min:10|max:3000' : 'nullable|string|max:3000']);
return response()->json($service->change($cluster, $request->user()->id, (int) $data['expected_version'], $action, $data))
->header('Cache-Control', 'private, no-store');
}
}
@@ -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()));
}
}
@@ -1,86 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\OpportunityManagement;
use App\Services\OpportunityPromotion;
use App\Services\PortalProfileAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
final class CorridorAgribusinessController extends Controller
{
private function manifest(CorridorAgribusinessOpportunities $service): string
{
return hash('sha256', json_encode($service->entries(), JSON_THROW_ON_ERROR));
}
public function show(Request $request, CorridorAgribusinessOpportunities $service): Response
{
$access = app(PortalProfileAccess::class);
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');
foreach ($records as $record) {
abort_unless($access->canView($request->user(), $record), 403);
}
return Inertia::render('management/corridor-agribusiness', ['manifestHash' => $this->manifest($service),
'canRegister' => (bool) $access->internalAssignment($request->user(), 'investment_project'),
'entries' => array_map(function ($entry) use ($records) {
$r = $records->get($entry['key']);
return [...$entry, 'id' => $r?->id, 'status' => $r?->status, 'href' => $r ? '/oportunidades/'.$r->slug : null];
}, $service->entries())]);
}
public function register(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse
{
$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.');
$access = app(PortalProfileAccess::class);
$grant = $access->internalAssignment($request->user(), 'investment_project');
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)]);
}
public function preview(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse
{
$previews = [];
foreach ($service->entries() as $entry) {
$record = Opportunity::where('management_creation_key', $entry['key'])->firstOrFail();
$input = data_get($record->workflow_context, 'promotional_summary.input');
abort_unless(is_array($input), 409, 'Complete a selecção pública na divulgação individual.');
$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)])];
}
return response()->json(['previews' => $previews]);
}
public function disclose(Request $request, CorridorAgribusinessOpportunities $service): JsonResponse
{
$data = $request->validate(['confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000',
'previews' => 'required|array|size:34', 'previews.*.id' => 'required|integer|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();
$supplied = array_column($data['previews'], 'id');
sort($supplied);
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) {
$result = [];
foreach ($data['previews'] as $preview) {
$result[] = app(OpportunityPromotion::class)->confirm($request->user(), $preview['id'],
[...$preview, 'confirm' => true, 'reason' => $data['reason']]);
}
return ['records' => $result];
}));
}
}
+26 -64
View File
@@ -10,14 +10,9 @@ use App\Models\Lead;
use App\Models\User;
use App\Services\ConvertInterestToLead;
use App\Services\CountryCatalogue;
use App\Services\CrmNextAction;
use App\Services\CrmTaskCreation;
use App\Services\CrmTaskPreparations;
use App\Services\DeclaredOriginStatistics;
use App\Services\PortalAudit;
use App\Services\VirtualDesk;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -28,12 +23,6 @@ use Inertia\Response;
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
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
@@ -54,12 +43,10 @@ class CrmController extends Controller
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'])],
'status' => ['required', Rule::in(['prospect', 'active', 'inactive'])]], self::VERSION_MESSAGES);
$version = (int) $data['version'];
unset($data['version']);
PortalAudit::save($investor, $data, $request->user()->id, $version);
'status' => ['required', Rule::in(['prospect', 'active', 'inactive'])]]);
PortalAudit::save($investor, $data, $request->user()->id);
return back()->with('success', 'Investidor actualizado.');
}
@@ -67,17 +54,15 @@ class CrmController extends Controller
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);
$data = $request->validate(['version' => 'required|integer|min:1', 'full_name' => ['required', 'string', 'max:150'],
'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30']], self::VERSION_MESSAGES);
$data = $request->validate(['full_name' => ['required', 'string', 'max:150'],
'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30']]);
$data['email'] = strtolower(trim($data['email']));
$version = (int) $data['version'];
unset($data['version']);
DB::transaction(function () use ($contact, $data, $request, $version) {
DB::transaction(function () use ($contact, $data, $request) {
DB::select('select pg_advisory_xact_lock(170925, hashtext(?))', [$data['email']]);
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.']);
}
PortalAudit::save($contact, $data, $request->user()->id, $version);
PortalAudit::save($contact, $data, $request->user()->id);
});
return back()->with('success', 'Contacto actualizado.');
@@ -97,7 +82,7 @@ class CrmController extends Controller
'owner_user_id' => ['nullable', 'integer', 'exists:users,id'],
'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]);
$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) {
$query->where(fn ($q) => $q->whereLike('title', "%{$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.');
}
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')]);
$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')
->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),
'nextAction' => ['future_tasks' => app(CrmNextAction::class)->future($lead)->count()]]);
return Inertia::render('crm/lead', ['lead' => $lead, 'contacts' => $contacts, 'owners' => $this->owners()]);
}
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'])],
'qualification_reason' => ['required', 'string', 'min:10', 'max:2000']], self::VERSION_MESSAGES);
$version = (int) $data['version'];
unset($data['version']);
DB::transaction(function () use ($lead, $data, $request, $version) {
'qualification_reason' => ['required', 'string', 'min:10', 'max:2000']]);
DB::transaction(function () use ($lead, $data, $request) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
if ($lead->status === 'converted') {
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.']);
}
PortalAudit::save($lead, $data, $request->user()->id, $version);
PortalAudit::save($lead, $data, $request->user()->id);
});
return back()->with('success', 'Lead actualizado.');
@@ -180,39 +157,24 @@ class CrmController extends Controller
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.');
}
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));
return back()->with('success', 'Tarefa criada.');
}
public function updateTask(Request $request, Lead $lead, CrmTask $task): RedirectResponse
{
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);
$version = (int) $data['version'];
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);
}
$data = $request->validate(['status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])]]);
DB::transaction(function () use ($lead, $task, $data, $request) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
$task = CrmTask::lockForUpdate()->findOrFail($task->id);
abort_unless($task->lead_id === $lead->id, 404);
app(CrmNextAction::class)->assertMayFinish($lead, $task, $data['status']);
PortalAudit::save($task, $data, $request->user()->id, $version);
if (in_array($lead->status, ['new', 'qualified'], true) && in_array($data['status'], ['done', 'cancelled'], true)
&& ! $lead->tasks()->where('id', '!=', $task->id)->whereIn('status', ['open', 'in_progress'])->where('due_at', '>', now())->exists()) {
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);
});
return back()->with('success', 'Tarefa actualizada.');
+3 -10
View File
@@ -5,7 +5,6 @@ namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\DashboardWorkQueue;
use App\Services\InvestorPath;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
@@ -17,18 +16,12 @@ class DashboardController extends Controller
{
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']);
$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')) {
if (! $request->user()->can('manage-portal')) {
$path = app(InvestorPath::class);
$contexts = $path->contexts($request->user());
$current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null;
return Inertia::render('dashboard', ['summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [
return Inertia::render('dashboard', ['summary' => null, 'investorPath' => $path->available() ? [
'contexts' => $contexts,
'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))),
@@ -43,7 +36,7 @@ class DashboardController extends Controller
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
}
return Inertia::render('dashboard', ['workQueue' => $workQueue, 'summary' => [
return Inertia::render('dashboard', ['summary' => [
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
@@ -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;
use App\Services\Malware\DocumentScanRetry;
use Illuminate\Http\JsonResponse;
use App\Jobs\ScanDocumentForMalware;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
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);
$message = match ($result['outcome']) {
'accepted' => 'Pedido de análise aceite pelo serviço de envio. Acompanhe o estado do ficheiro até haver resultado.',
'duplicate' => 'Já existe uma reserva de análise para este ficheiro. Não foi enviado outro pedido. Acompanhe o estado antes de repetir.',
'stale' => 'O ficheiro ou o seu acesso mudou. Actualize a página para consultar o estado antes de repetir.',
'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.',
};
DB::transaction(function () use ($id, $request): void {
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first();
abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
if ($request->expectsJson()) {
return response()->json([...$result, 'message' => $message], $result['outcome'] === 'unconfirmed' ? 503 : 200)->header('Cache-Control', 'no-store, private');
}
DB::table('documents')->where('id', $id)->update([
'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,79 +2,63 @@
namespace App\Http\Controllers;
use App\Services\DocumentManagementAccess;
use App\Services\EditorialInstallation;
use App\Services\EditorialReading;
use App\Services\PortalProfileAccess;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
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
{
public function installation(Request $request, EditorialInstallation $installation): Response
public function installation(Request $request, \App\Services\EditorialInstallation $installation)
{
$plan = $installation->preview($request->user());
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());
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);
}
public function confirmMfa(Request $request): Response
public function confirmMfa(Request $request)
{
$actor = $request->user();
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');
}
if ($request->isMethod('get')) {
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']);
try {
$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(
Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code']
$valid = $recovery !== null || (!empty($data['code']) && app(\Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider::class)->verify(
\Laravel\Fortify\Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code']
));
} catch (DecryptException $e) {
} catch (\Illuminate\Contracts\Encryption\DecryptException $e) {
$valid = false;
}
if (! $valid) {
event(new TwoFactorAuthenticationFailed($actor));
if (!$valid) {
event(new \Laravel\Fortify\Events\TwoFactorAuthenticationFailed($actor));
throw ValidationException::withMessages(['code' => 'Não foi possível confirmar o código MFA.']);
}
if ($recovery !== null) {
$actor->replaceRecoveryCode($recovery);
}
event(new ValidTwoFactorAuthenticationCodeProvided($actor));
event(new \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($actor));
$request->session()->regenerate();
return redirect()->route('editorial.installation')->header('Cache-Control', 'private, no-store');
}
@@ -83,7 +67,7 @@ class EditorialArticleController extends Controller
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')
->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized')
@@ -91,29 +75,41 @@ class EditorialArticleController extends Controller
->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();
app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($r instanceof \stdClass, 404);
$reading = app(EditorialReading::class);
$original = $reading->original($r);
$content = $reading->content($r, $original);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->find($id);
abort_unless($r, 404);
$content = $r->editorial_content ? json_decode($r->editorial_content, true) : null;
if ($content === null) {
$document = DB::table('documents')->find($r->document_id);
if ($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
&& in_array($document->media_type, ['text/plain', 'application/json'], true)) {
$binary = app(VerifiedDocumentBinary::class)->respond($document);
$bytes = $binary instanceof \Symfony\Component\HttpFoundation\BinaryFileResponse
? file_get_contents($binary->getFile()->getPathname()) : $binary->getContent();
$original = json_decode($bytes, true);
if (is_array($original) && ($original['format'] ?? '') === 'investmoz-legacy-article-v1') {
$html = preg_replace('~</(?:p|div|li|h[1-6])>|<br\s*/?>~i', "\n\n", $original['content_html'] ?? '');
$content = ['body' => trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')), 'image_resource_id' => null];
}
}
}
return tap(Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
'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'));
return Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version],
'content' => $content, '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'])]);
}
public function save(Request $request, int $id): RedirectResponse
public function save(Request $request, int $id)
{
$this->installed();
$data = $this->validateContent($request);
$data = $request->validate(['body' => ['required', 'string', 'max:100000'], 'image_resource_id' => ['nullable', 'integer'], 'metadata_version' => ['required', 'integer', 'min:1']]);
DB::transaction(function () use ($request, $id, $data) {
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_unless($r, 404);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
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.');
@@ -122,10 +118,10 @@ class EditorialArticleController extends Controller
if ($body === '') {
throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']);
}
$content = $this->edition($r, $data);
$content = ['format' => 1, 'body' => $body, 'image_resource_id' => $data['image_resource_id'] ?? null];
$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()]);
$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();
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])]);
@@ -134,64 +130,16 @@ 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 array<array-key, mixed>
*/
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']]);
}
/**
* @param array<array-key, mixed> $data
* @return array<array-key, mixed>
*/
private function edition(\stdClass $resource, array $data): array
{
$reading = app(EditorialReading::class);
$original = $reading->original($resource);
$mode = $data['image_mode'] ?? (! empty($data['image_resource_id']) ? 'selected' : 'none');
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.');
}
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'])];
}
public function preview(Request $request, int $id): Response
public function show(int $id)
{
$this->installed();
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$r = $this->publicResources()->where('r.id', $id)->first(['r.id', 'r.title', 'r.description', 'r.editorial_content', 'r.document_id', 'd.issuer']);
abort_unless($r && $r->editorial_content, 404);
$content = json_decode($r->editorial_content, true);
// Recheck the original bytes using the same binary gate as the library.
$binary = app(VerifiedDocumentBinary::class)->respond($this->publicResources()->where('r.id', $id)->first(['r.*', 'd.*']));
$image = ! empty($content['image_resource_id']) ? $this->publicResources()->where('r.id', $content['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->value('r.id') : null;
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
{
$this->installed();
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->where('id', $id)->first();
abort_unless($resource instanceof \stdClass, 404);
$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.');
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');
}
public function show(Request $request, int $id): Response
{
$this->installed();
$resource = app(EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']);
abort_unless($resource !== null, 404);
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'));
return Inertia::render('portal/article', ['article' => ['id' => $id, 'title' => $r->title, 'description' => $r->description, 'author' => $r->issuer, 'body' => $content['body'], 'image_url' => $image ? '/documentos/'.$image.'/view' : null, 'original_url' => '/documentos/'.$id]]);
}
}
+44 -127
View File
@@ -11,45 +11,28 @@ use App\Models\Sector;
use App\Models\Skill;
use App\Models\TrainingCapacity;
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\EducationReviews;
use App\Services\EducationWorkQueue;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
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 = $this->filters($request);
if (($filters['scope'] ?? 'all') === 'review') {
unset($filters['status']);
}
$draftService = app(EducationDeclarationDrafts::class);
$draftsAvailable = $draftService->installed();
$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();
$filters = $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'],
]);
$demands = LabourDemand::query();
$capacities = TrainingCapacity::query();
$territoryIds = [];
@@ -70,36 +53,20 @@ class EducationController extends Controller
}
}
// Viewing one's work must not remove existing territorial catalogue evidence.
$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');
$courses = Course::where('status', '!=', 'retired')
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'));
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'));
$providers->whereIn('id', (clone $courses)->select('provider_id'));
}
return tap(Inertia::render('education/index', [
'workQueue' => app(EducationWorkQueue::class)->summary($request->user()),
'draftsAvailable' => $draftsAvailable, 'selectedDraft' => $selectedDraft,
'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)),
return Inertia::render('education/index', [
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
'filters' => $filters,
'indicators' => [
'demand_records' => LabourDemand::count(),
@@ -116,63 +83,9 @@ class EducationController extends Controller
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']),
'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
{
$model = match ($kind) {
@@ -199,41 +112,45 @@ class EducationController extends Controller
public function store(Request $request, string $kind): RedirectResponse
{
$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,
[...$data, 'submitted_by' => $request->user()->id, 'status' => 'submitted'], $request->user()->id);
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
{
$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
@@ -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\Policies\ExistingBusinessPolicy;
use App\Services\BusinessFormSchema;
use App\Services\BusinessPointPublication;
use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake;
use App\Services\ExistingBusinessObjects;
use App\Services\ExistingBusinessProfileCategory;
use App\Services\ExistingBusinessVerification;
use App\Services\InvestmentGeography;
use App\Services\Mne\BusinessRepository;
@@ -69,8 +67,7 @@ class ExistingBusinessController extends Controller
$business = app(BusinessRepository::class)->find($id);
Gate::authorize('view', $business);
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),
'pointPublicationAvailable' => app(BusinessPointPublication::class)->available(), 'registeredLocation' => app(BusinessPointPublication::class)->registered($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),
'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
'canTriage' => $request->user()->can('triage', $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,173 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Services\EditorialReading;
use App\Services\FederatedSearch;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use App\Services\SearchTerms;
use App\Services\SectorOrganization;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
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']]);
$term = trim($input['q'] ?? '');
$tokens = $search->tokens($term);
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
if ($tokens !== []) {
$factories = [
'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) {
$query = InvestmentPortfolioController::visible();
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
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]);
},
];
$results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
fn ($item) => $search->score($item['title'], $term, $tokens));
}
return Inertia::render('portal/search', ['query' => $term, 'results' => $results]);
}
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. */
private function pages(): array
{
return [
['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.');
}
}
@@ -1,34 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentComplements;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class InvestmentComplementController extends Controller
{
public function index(Request $request, InvestmentComplements $service): JsonResponse
{
$data = $request->validate(['origin' => [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']);
$items = [];
$issues = [];
foreach ($data['contexts'] ?? [['origin' => $data['origin'], 'territory_id' => $data['territory_id'] ?? null]] as $context) {
try {
$items = [...$items, ...$service->options($context['origin'], $context['territory_id'] ?? null)];
} catch (ModelNotFoundException|HttpException $e) {
$status = $e instanceof HttpException ? $e->getStatusCode() : 404;
if (! isset($data['contexts']) || $status !== 404) {
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.'];
}
}
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\Opportunity;
use App\Models\Sector;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentComplements;
use App\Services\InvestmentGeography;
use App\Services\InvestmentMapIndicators;
use App\Services\InvestmentZoneOperationalDetails;
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\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -33,21 +24,11 @@ class InvestmentDiscoveryController extends Controller
if ($request->routeIs('investment.education')) {
$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),
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(OpportunityCorridorExplorer::class)->groups($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()),
'results' => $results, 'relatedSearch' => $related,
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
}
@@ -92,11 +73,7 @@ class InvestmentDiscoveryController extends Controller
public function search(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
$filters = $catalogue->filters($request);
$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);
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString());
}
public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse
@@ -111,7 +88,7 @@ class InvestmentDiscoveryController extends Controller
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
if ($dossierOrigin) {
$linked = in_array($record->id, app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true);
$linked = false;
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()) {
$linked = true;
@@ -129,8 +106,8 @@ class InvestmentDiscoveryController extends Controller
$returnFilters = $request->query();
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,
'guides' => app(SectorInvestment::class)->guides($record->sector),
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
}
@@ -157,7 +134,6 @@ class InvestmentDiscoveryController extends Controller
$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]);
$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)) {
$zoneFilters = $filters;
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
@@ -169,12 +145,10 @@ class InvestmentDiscoveryController extends Controller
$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'] : [])]);
$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]));
$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]));
}
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
@@ -204,16 +178,15 @@ class InvestmentDiscoveryController extends Controller
{
$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');
if ($stream === false) {
return;
}
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) {
$location = $catalogue->locationPresentation($r);
$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']];
$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)];
fputcsv($stream, array_map(fn ($v) => preg_match('/^[\s]*[=+@-]/u', (string) $v) ? "'".$v : $v, $fields), ';', '"', '');
}
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,14 +6,10 @@ use App\Models\InvestmentDossier;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Project;
use App\Services\ClusterCompetitiveness;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentComplements;
use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\InvestmentZones;
use App\Services\PortalAudit;
use App\Services\PublicSearchMetrics;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\RedirectResponse;
@@ -65,15 +61,8 @@ class InvestmentPortfolioController extends Controller
{
$management = $request->routeIs('management.portfolio*');
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project'],
'sector' => ['nullable', 'string', 'max:500'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
$query = $management ? InvestmentDossier::query() : self::visible();
$sectorOptions = ($management ? InvestmentDossier::query() : self::visible())
->whereNotNull('content->sector')->get()->pluck('content.sector')
->filter(fn ($sector) => is_string($sector) && trim($sector) !== '')->unique()->sort()->values();
if (! empty($filters['sector'])) {
$query->where('content->sector', $filters['sector']);
}
if (! empty($filters['kind'])) {
$query->whereNotNull($filters['kind'].'_id');
}
@@ -86,13 +75,8 @@ class InvestmentPortfolioController extends Controller
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds);
}
$results = $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]);
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
'management' => $management, 'filters' => $filters, 'territorySelection' => $territorySelection]);
}
public function show(Request $request, string $id): Response
@@ -121,7 +105,6 @@ class InvestmentPortfolioController extends Controller
$opportunities = Opportunity::published()->where(function ($query) use ($record) {
$query->whereRaw('false');
$query->orWhereIn('id', app(CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($record));
foreach (['clusters' => 'cluster_id', 'corridors' => 'corridor_id', 'projects' => 'project_id'] as $relation => $foreign) {
if ($record->$foreign) {
$query->orWhereIn('id', DB::table('opportunity_'.$relation)->where($foreign, $record->$foreign)->select('opportunity_id'));
@@ -147,21 +130,12 @@ class InvestmentPortfolioController extends Controller
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect();
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
return Inertia::render('portfolio/show', ['record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
}
public function resumeInterest(Request $request, string $id): RedirectResponse
{
self::visible()->findOrFail($id);
$data = $request->validate(['catalogue_query' => ['nullable', 'string', 'max:4000']]);
return redirect('/carteira/'.$id.'?'.http_build_query([...$data, 'prepare_interest' => '1']));
}
public function save(Request $request, InvestmentDossier $dossier): RedirectResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
@@ -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,7 +8,6 @@ use App\Models\EconomicCluster;
use App\Models\InvestmentGap;
use App\Models\Project;
use App\Models\ValueChain;
use App\Services\ClusterCompetitiveness;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
@@ -51,7 +50,6 @@ class InvestmentStructureController extends Controller
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
->orderBy('origins.code')->orderBy('destinations.code')
->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,
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
@@ -10,7 +10,6 @@ use App\Models\Opportunity;
use App\Services\CountryCatalogue;
use App\Services\InterestSubmission;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentConsumerScope;
use App\Services\InvestmentWorkspace;
use App\Services\InvestmentZones;
use App\Services\InvestorContextConsumers;
@@ -152,7 +151,7 @@ class InvestmentSubmissionController extends Controller
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_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']);
$result = DB::transaction(function () use ($request, $id, $data) {
// Serialize all links for this submission before locking the selected case.
@@ -171,10 +170,8 @@ class InvestmentSubmissionController extends Controller
// Binding does not save form edits or assert that the context has been reviewed.
$this->event($row, $request, 'context_bound');
$desk->event($desk->case($request->user(), $case->id), $request->user(), 'manifestation_context_bound', ['submission_id' => $id, 'previous_case_version' => (int) $case->version]);
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version + 1, 'replayed' => false];
}, 3);
return response()->json($result);
}
@@ -193,14 +190,11 @@ class InvestmentSubmissionController extends Controller
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
'payload' => ['required', 'array:context_opportunity,context_opportunity_snapshot,context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload' => ['required', 'array:full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.context_opportunity' => ['nullable', 'string', 'max:100'],
'payload.context_opportunity_snapshot' => ['nullable', 'string', 'max:200000'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.other_details' => ['nullable', 'string', 'max:5000'],
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
@@ -250,13 +244,6 @@ 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();
if ($bound) {
$data['payload'] = app(InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting);
} else {
unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
}
return $data;
}
@@ -276,7 +263,7 @@ class InvestmentSubmissionController extends Controller
$version = DB::transaction(function () use ($request, $id, $data) {
$row = $this->owned($request, $id, true, true);
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(['payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -320,8 +307,7 @@ class InvestmentSubmissionController extends Controller
return InterestRequest::findOrFail($row->interest_request_id)->reference;
}
abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.');
$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 = $row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null;
if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
}
@@ -342,14 +328,12 @@ class InvestmentSubmissionController extends Controller
'sector_interest' => $p['sector'], 'message' => $p['objective'], 'privacy_consent' => true, 'consented_at' => now(),
'status' => 'received', 'source_page' => '/investidor/manifestacoes/'.$id,
'metadata' => ['submission_id' => $id, 'portal_user_id' => $request->user()->id, 'dossier_id' => $row->dossier_id,
'context_opportunity' => $p['context_opportunity'] ?? null,
'context_opportunity_snapshot' => $p['context_opportunity_snapshot'] ?? null,
'declared_origin_country_code' => $p['declared_origin_country_code'] ?? null,
'residence_country_code' => $p['residence_country_code'] ?? null,
'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []),
...($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(['payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -16,24 +16,15 @@ class InvestmentTerritoryController extends Controller
{
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/*');
$management = $request->is('management/*') || $profile;
$actor = $request->user();
$access = app(PortalProfileAccess::class);
// The general consumers use this same endpoint while authenticated.
// Reuse the read access already granted by the private cadastral routes.
$profileAccess = $actor && $actor->hasVerifiedEmail()
&& ($access->grants($actor) !== [] || $access->superuser($actor));
$internal = $profileAccess || ($actor?->hasVerifiedEmail() && $actor->can('manage-portal'));
if ($management) {
abort_unless($profile ? $profileAccess : $internal, 403);
$access = app(PortalProfileAccess::class);
abort_unless($profile ? ($access->grants($request->user()) !== [] || $access->superuser($request->user())) : $request->user()?->can('manage-portal'), 403);
}
return response()->json([
...app(TerritoryGeometry::class)->context($data['territory_id'], $internal, $request->boolean('include_children')),
'geometry_scope' => $internal ? 'authorized_internal' : 'public',
])->header('Cache-Control', 'private, no-store');
return response()->json(app(TerritoryGeometry::class)->context($data['territory_id'], $management));
}
private const TABLES = ['opportunities' => 'opportunities', 'projects' => 'projects', 'clusters' => 'economic_clusters', 'corridors' => 'development_corridors', 'zones' => 'investment_zones'];
@@ -3,18 +3,10 @@
namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
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\OpportunityManagement;
use App\Services\OrganizationCountry;
use App\Services\Procurement;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -26,9 +18,9 @@ use Inertia\Response;
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) {
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$company->countrySource = $company->payload['organization_country_snapshot'] ?? null;
@@ -36,17 +28,12 @@ class InvestmentWorkspaceController extends Controller
return $company;
})];
Inertia::encryptHistory();
$response = $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data)->toResponse($request);
$response->headers->set('Cache-Control', 'no-store, private');
return $response;
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data);
}
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.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'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']]);
$version = DB::transaction(function () use ($request, $data) {
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();
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Perfil alterado. Recarregue.');
$version = (int) $data['version'] + 1;
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()]);
$this->audit($request, 'profile_saved', 'investment_accounts', null, ['version' => $version]);
$commands->record($request->user(), $data['operation_key'] ?? null, $command, ['version' => $version]);
return $version;
});
@@ -76,7 +56,7 @@ class InvestmentWorkspaceController extends Controller
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.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'],
@@ -86,12 +66,6 @@ class InvestmentWorkspaceController extends Controller
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;
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_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();
@@ -105,7 +79,6 @@ class InvestmentWorkspaceController extends Controller
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()]);
$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];
});
@@ -122,25 +95,13 @@ class InvestmentWorkspaceController extends Controller
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) {
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();
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.');
$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($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['incorporation_country_code'] = $snapshot['country'];
$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()]);
$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']]);
$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 $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
{
$query = DB::table('investment_notifications')->where('user_id', $request->user()->id);
$alerts = app(InvestmentOpportunityAlerts::class);
$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')) {
if (! $request->user()->can('manage-portal')) {
$query->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id'));
}
$data = ['alertPreferences' => $available ? $alerts->preferences($request->user()->id) : null,
'alertOptions' => $available ? ['frequencies' => InvestmentOpportunityAlerts::FREQUENCIES,
'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);
$data = ['unread' => (clone $query)->whereNull('read_at')->count(), 'notifications' => $query->orderByDesc('created_at')->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);
}
@@ -199,17 +135,7 @@ class InvestmentWorkspaceController extends Controller
{
$row = DB::table('investment_notifications')->where('id', $id)->where('user_id', $request->user()->id)->first();
abort_unless($row !== null, 404);
if ($row->opportunity_id ?? null) {
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);
}
$this->submission($request, $row->submission_id);
DB::table('investment_notifications')->where('id', $id)->whereNull('read_at')->update(['read_at' => now()]);
return $request->expectsJson() ? response()->json(['read' => true]) : back();
@@ -5,18 +5,14 @@ namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Services\EconomicReferenceCatalog;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentTerritorialTraceability;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZoneOperationalDetails;
use App\Services\InvestmentZoneOperationalDetailsInstallation;
use App\Services\InvestmentZones;
use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestmentZoneProfileInstallation;
use App\Services\InvestmentZones;
use App\Services\PublicSearchMetrics;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -37,7 +33,7 @@ class InvestmentZoneController extends Controller
$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('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'];
$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'))
@@ -72,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,
'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'),
'related' => $related->unique('id')->values(),
'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
@@ -81,7 +77,7 @@ class InvestmentZoneController extends Controller
'dossiers' => InvestmentDossier::orderBy('title')->limit(200)->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id'])] : null,
'opportunities' => $opportunities,
'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']) : [],
'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
'locations' => $locations,
@@ -103,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.');
$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'];
if (app(InvestmentZoneOperationalDetails::class)->installed()) {
$publicColumns[] = 'operational_details';
}
if(app(\App\Services\InvestmentZoneOperationalDetails::class)->installed()){$publicColumns[]='operational_details';}
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString();
if (! $management) {
app(PublicSearchMetrics::class)->candidate($request, 'zones', $rows->total(), $filters);
}
$rows->getCollection()->transform(function (InvestmentZone $zone) use ($management) {
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
@@ -122,13 +113,11 @@ class InvestmentZoneController extends Controller
->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']));
if (! $management) {
$zone->makeHidden('primary_territory_id');
}
if (! $management) { $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')];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
@@ -204,35 +193,35 @@ class InvestmentZoneController extends Controller
'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'],
'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.*.id' => ['nullable', 'uuid'],
'activities.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'activities.*.kind' => ['nullable', 'in:infrastructure,service,access,other'],
'activities.*.scope' => ['nullable', 'in:inside,regional,unknown'],
'activities.*.capacity_type' => ['nullable', 'string', 'max:120'],
'activities.*.capacity_value' => ['nullable', 'numeric', 'min:0'],
'activities.*.capacity_unit' => ['nullable', 'string', 'max:40'],
'activities.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'],
'activities.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'],
'activities.*.support_document_ids' => ['sometimes', 'array', 'max:10'],
'activities.*.id' => ['nullable','uuid'],
'activities.*.source_date' => ['nullable','date_format:Y-m-d'],
'activities.*.kind' => ['nullable','in:infrastructure,service,access,other'],
'activities.*.scope' => ['nullable','in:inside,regional,unknown'],
'activities.*.capacity_type' => ['nullable','string','max:120'],
'activities.*.capacity_value' => ['nullable','numeric','min:0'],
'activities.*.capacity_unit' => ['nullable','string','max:40'],
'activities.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
'activities.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
'activities.*.support_document_ids' => ['sometimes','array','max:10'],
'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.*.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.*.id' => ['nullable', 'uuid'],
'resources.*.source_date' => ['nullable', 'date_format:Y-m-d'],
'resources.*.kind' => ['nullable', 'in:infrastructure,service,access,other'],
'resources.*.scope' => ['nullable', 'in:inside,regional,unknown'],
'resources.*.capacity_type' => ['nullable', 'string', 'max:120'],
'resources.*.capacity_value' => ['nullable', 'numeric', 'min:0'],
'resources.*.capacity_unit' => ['nullable', 'string', 'max:40'],
'resources.*.focus' => ['nullable', 'in:promotional,potential,installed,unknown'],
'resources.*.economic_activity_id' => ['nullable', 'uuid', 'exists:economic_activities,id'],
'resources.*.support_document_ids' => ['sometimes', 'array', 'max:10'],
'resources.*.id' => ['nullable','uuid'],
'resources.*.source_date' => ['nullable','date_format:Y-m-d'],
'resources.*.kind' => ['nullable','in:infrastructure,service,access,other'],
'resources.*.scope' => ['nullable','in:inside,regional,unknown'],
'resources.*.capacity_type' => ['nullable','string','max:120'],
'resources.*.capacity_value' => ['nullable','numeric','min:0'],
'resources.*.capacity_unit' => ['nullable','string','max:40'],
'resources.*.focus' => ['nullable','in:promotional,potential,installed,unknown'],
'resources.*.economic_activity_id' => ['nullable','uuid','exists:economic_activities,id'],
'resources.*.support_document_ids' => ['sometimes','array','max:10'],
'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.*.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'],
@@ -268,26 +257,17 @@ class InvestmentZoneController extends Controller
$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);
}
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']]);
if ($action === 'reconcile') {
$request->validate(['base_public_version' => 'required|integer|min:1']);
$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);
});
} else {
$saved = $action === 'publish' ? $zones->publish($id, $data['version_no'], $request->user()->id) : $zones->suspend($id, $data['version_no'], $request->user()->id);
}
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'base_public_version'=>['required_if:action,reconcile','nullable','integer','min:1']]);
if($action==='reconcile'){
$request->validate(['base_public_version'=>'required|integer|min:1']);
$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);});
}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));
}
@@ -2,191 +2,72 @@
namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Services\InvestmentComplements;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography;
use App\Services\InvestmentOpportunityChoices;
use App\Services\InvestorContext;
use App\Services\InvestorContextInstallation;
use App\Services\InvestorPath;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
final class InvestorContextController extends Controller
{
/**
* @return array<array-key, mixed>
*/
private function entryComplements(Request $request): array
{
$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']);
if (empty($data['complement_origin'])) {
return [];
}
return app(InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'],
'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]);
}
/**
* @return array<array-key, mixed>
*/
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',
'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;
$ids = $data['opportunities'] ?? (($source['kind'] ?? '') === 'opportunity' ? [$source['id']] : []);
abort_if(! empty($data['from_dossier']) && ! $ids, 422, 'Escolha a oportunidade associada à ficha da carteira.');
$path = app(InvestmentGeography::class)->selection($data);
$input = array_map(fn ($id) => ['source' => 'opportunity:'.$id, ...($path ? ['territory' => 'territory:'.$path[array_key_last($path)]->id] : [])], $ids);
if (! empty($data['from_dossier'])) {
$input[0]['portfolio_origins'] = [$data['from_dossier']];
}
foreach ($this->entryComplements($r) as $complement) {
$keys = array_filter([$complement['origin']['key'], $complement['target']['key'] ?? null], fn ($key) => str_starts_with($key, 'opportunity:'));
foreach ($keys as $key) {
if (in_array($key, array_column($input, 'source'), true)) {
continue;
}
$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)]] : [])];
}
}
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()));
}
public function focus(Request $r, InvestorContext $context): JsonResponse
public function focus(Request $r, InvestorContext $context)
{
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']);
return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null));
return response()->json($context->view($r->user(), $id));
}
public function options(Request $r, InvestorContext $context): JsonResponse
public function options(Request $r, InvestorContext $context)
{
$d = $r->validate(['q' => 'nullable|string|max:120']);
return response()->json($context->options($d['q'] ?? ''));
}
public function corridorOptions(Request $r): JsonResponse
{
$data = $r->validate(['corridor' => ['nullable', Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
$service = app(InvestmentCorridorContext::class);
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)]);
}
public function saveDraft(Request $r, string $id, InvestorContext $context): JsonResponse
{
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);
abort_unless($data['can_write'], 403);
$entries = $this->entryChoices($r, $context);
$choices = $entries ?: null;
$source = $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null;
if ($choices === null && ($source['kind'] ?? '') === 'opportunity') {
$choices = app(InvestmentOpportunityChoices::class)->resolve([['source' => $source['key']]]);
}
$explicitChoices = $choices;
$selectionKey = $r->validate(['selection_key' => 'nullable|uuid'])['selection_key'] ?? null;
$entryKey = fn ($c) => $c['opportunity']['key'].($selectionKey ? '@'.$selectionKey : '');
$entrySources = array_map($entryKey, $explicitChoices ?? []);
if ($choices !== null) {
$choices = app(InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices);
}
$draft = $context->draft($r->user(), $id);
if ($draft && isset($draft['opportunity_choices'])) {
$recovered = app(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));
$choices = app(InvestmentOpportunityChoices::class)->mergeEntries($recovered, array_values($new));
}
$differences = [];
foreach ($explicitChoices ?? [] as $entry) {
$kept = collect($choices ?? [])->firstWhere('opportunity.key', $entry['opportunity']['key']);
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'];
}
}
$complements = isset($draft['complements']) ? app(InvestmentComplements::class)->resolve($draft['complements'], $data['context']['complements'] ?? []) : ($data['context']['complements'] ?? []);
$entryTerritory = null;
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']));
if ($path) {
$entryTerritory = $context->reference('territory:'.$path[array_key_last($path)]->id);
}
}
foreach ($this->entryComplements($r) as $entry) {
$tag = 'complement:'.hash('sha256', $entry['key'].'@'.($selectionKey ?? ''));
$entrySources[] = $tag;
if (! in_array($tag, $draft['entry_sources'] ?? [], true)) {
$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', ['initial' => $data, 'options' => $context->options(), 'source' => $r->query('source') ? $context->reference($r->validate(['source' => 'required|string|max:100'])['source']) : null, 'territorySelection' => isset($data['context']['selection']['territory']) ? app(InvestmentGeography::class)->path($data['context']['selection']['territory']['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()));
}
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()));
}
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])$/']]);
$ref = isset($d['source']) ? $context->reference($d['source']) : null;
$submissionId = $r->validate(['submission' => 'nullable|uuid'])['submission'] ?? null;
$entrySubmission = null;
if ($submissionId) {
$submission = 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.');
$entrySubmission = ['id' => $submission->id, 'version' => $submission->version];
}
$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', ['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());
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()));
}
}
+17 -58
View File
@@ -2,15 +2,11 @@
namespace App\Http\Controllers;
use App\Services\CompanyComparison;
use App\Services\FintechInvestment;
use App\Services\InvestmentGuides;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestorPath;
use App\Services\InvestmentZonePathContext;
use App\Services\LegacyContent;
use App\Services\SectorResourceReferences;
use App\Services\SectorInvestment;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -23,9 +19,7 @@ class InvestorPathController extends Controller
public function index(Request $request, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
$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];
$data = ['contexts' => $path->contexts($request->user())];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
}
@@ -34,9 +28,6 @@ class InvestorPathController extends Controller
{
abort_unless($path->available(), 404);
$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);
}
@@ -48,58 +39,36 @@ class InvestorPathController extends Controller
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);
$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'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
abort_unless($path->available(),404);
$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'])];
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());
return $request->expectsJson() ? response()->json(['ok' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência do parque guardada; não constitui reserva ou autorização.');
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.');
}
public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse
{
$comparison = $request->has('company_comparison') ? app(CompanyComparison::class)->answers(['answers' => $request->input('company_comparison')]) : null;
$guide = $request->has('investment_guide') ? app(InvestmentGuides::class)->selection($request->array('investment_guide')) : null;
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']);
}
$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;
if ($sector !== null) {
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');
if ($source !== null) {
$resourceId = filter_var($source, FILTER_VALIDATE_INT);
abort_unless(is_int($resourceId) && $resourceId !== 0, 422);
app(LegacyContent::class)->articleForResource($resourceId);
}
$id = DB::transaction(function () use ($path, $request, $source, $sector, $zone, $reference): 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 = DB::transaction(function () use ($path, $request, $source, $sector, $zone): string {
$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) {
$case = $path->view($request->user(), $id)['case'];
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
@@ -107,29 +76,19 @@ class InvestorPathController extends Controller
if ($sector !== null) {
$case = $path->view($request->user(), $id)['case'];
app(SectorInvestment::class)->attach($request->user(), $id, ['version' => $case['version'], 'sector_slug' => $sector, 'sector_reference' => $reference]);
}
if ($zone) {
$case = $path->view($request->user(), $id)['case'];
app(InvestmentZonePathContext::class)->attach($request->user(), $id, [...$zone, 'version' => $case['version'], 'confirm' => true]);
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]);}
return $id;
});
$href = $comparison === null ? '/investimentos/'.$id : '/investimentos/'.$id.'/forma-juridica?'.http_build_query(['answers' => $comparison]);
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.');
return $request->expectsJson() ? response()->json(['id' => $id], 201) : redirect('/investimentos/'.$id)->with('success', 'Investimento guardado no seu dossier.');
}
public function show(Request $request, string $id, InvestorPath $path): JsonResponse|Response
{
$choice = $request->validate(['opportunity' => 'nullable|string|max:100'])['opportunity'] ?? null;
$data = $path->view($request->user(), $id, $choice);
$data = $path->view($request->user(), $id);
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/show', $data);
}
@@ -377,9 +377,6 @@ class LandAssistanceController extends Controller
'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
{
$id = $case->payload['investor_path_case_id'] ?? null;
@@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\PublicSearchMetrics;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -25,7 +24,6 @@ class LegacyContentController extends Controller
$articles = array_values(array_filter($articles, fn ($item) => $q === ''
|| str_contains(mb_strtolower(strip_tags($item['html'].' '.$item['title'])), mb_strtolower($q))));
$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]);
}
@@ -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\OpportunityCanonicalContract;
use App\Services\OpportunityCanonicalResponse;
use App\Services\OpportunityCuration;
use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityPromotion;
use App\Services\OpportunityResources;
use App\Services\OpportunityTechnicalIncidents;
use App\Services\OpportunityWorkflowTasks;
use App\Services\PortalProfileAccess;
use App\Services\ProactiveInvestment;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -81,8 +76,8 @@ class OpportunityManagementController extends Controller
$query->where('opportunity_category', $filters['category']);
}
if ($filters['sector_id'] ?? null) {
$sector = Sector::whereKey($filters['sector_id'])->first();
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
$sector = Sector::find($filters['sector_id']);
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if ($filters['geography_scope'] ?? null) {
$query->where('geography_scope', $filters['geography_scope']);
@@ -108,7 +103,6 @@ class OpportunityManagementController extends Controller
$records = $query->orderByDesc('id')->paginate(20)->withQueryString();
$records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) {
$r->setAttribute('can_edit', $access->canEdit($request->user(), $r));
$r->setAttribute('can_promote', app(OpportunityPromotion::class)->allowed($request->user(), $r));
$r->setAttribute('working_status', $access->state($r));
$r->setAttribute('publication', $r->publicationPresentation());
$r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title);
@@ -131,19 +125,15 @@ class OpportunityManagementController extends Controller
$access->authorize($request->user(), 'view', $record);
}
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,
'workflowState' => $record ? $access->state($record) : null,
'profileReauthenticationRequired' => $record && $record->owner_user_id !== null
? app(OpportunityProfileWorkflow::class)->reauthenticationRequired($request->user(), $record) : false,
'publicationState' => $record?->publicationPresentation(),
'promotionHref' => $record && app(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,
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
'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) : [],
'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'),
'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) : [];
@@ -155,7 +145,7 @@ class OpportunityManagementController extends Controller
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;
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', ['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],
'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']) : []]);
@@ -1,28 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityPromotion;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class OpportunityPromotionController extends Controller
{
public function show(Request $request, int $id, OpportunityPromotion $service): Response
{
return Inertia::render('management/opportunity-promotion', $service->editor($request->user(), Opportunity::findOrFail($id)));
}
public function preview(Request $request, int $id, OpportunityPromotion $service): JsonResponse
{
return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all()));
}
public function confirm(Request $request, int $id, OpportunityPromotion $service): JsonResponse
{
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\InvestmentGeography;
use App\Services\OpportunityImageProcessor;
use App\Services\OpportunityLibrarySupport;
use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceInstallation;
use App\Services\OpportunityResources;
use App\Services\OpportunityTechnicalExecution;
use App\Services\OpportunityTechnicalIncidents;
use App\Services\OpportunityTerritorialPresentation;
use App\Services\OrganizationCountryInstallation;
use App\Services\PortalProfileAccess;
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));
}
public function index(int $id, OpportunityResources $service): \Symfony\Component\HttpFoundation\Response
public function index(int $id, OpportunityResources $service): Response
{
$opportunity = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
@@ -102,15 +98,12 @@ class OpportunityResourceController extends Controller
}
}
Inertia::encryptHistory();
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'));
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)]);
}
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.']);
}
@@ -120,18 +113,9 @@ class OpportunityResourceController extends Controller
if (is_string($input['payload'] ?? null)) {
$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);
};
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);
return response()->json(['id' => $record->id, 'message' => 'Recurso guardado no servidor. Não publicado; validação final e scan são independentes.', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))], $resourceId ? 200 : 201);
}
public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service): JsonResponse
@@ -143,14 +127,9 @@ class OpportunityResourceController extends Controller
public function prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse
{
$before = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize($request->user(), 'edit', $before);
$service->prepare($request->user(), $id, $resourceId, $request->all());
return app(OpportunityTechnicalExecution::class)->run($request->user(), $before, 'prepare_image', $request->all(), function () use ($request, $id, $resourceId, $service) {
$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.']);
});
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
@@ -171,18 +150,15 @@ class OpportunityResourceController extends Controller
{
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$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);
$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->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();
app(OpportunityLibrarySupport::class)->assertAvailable($support, $record);
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.');
}
app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($support, $record);
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.');
return app(VerifiedDocumentBinary::class)->respond($record);
}
@@ -2,35 +2,24 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
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\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\PublicSearchMetrics;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -56,11 +45,9 @@ class PartnershipExchangeController extends Controller
foreach (['sector_id', 'partnership_type', 'kind'] as $field) {
if (! empty($filters[$field])) {
if ($field === 'sector_id') {
$sector = Sector::whereKey($filters[$field])->first();
$query->whereIn('p.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} else {
$query->where('p.'.$field, $filters[$field]);
}
$sector = \App\Models\Sector::find($filters[$field]);
$query->whereIn('p.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
} else { $query->where('p.'.$field, $filters[$field]); }
}
}
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'])
->selectRaw("p.payload->>'summary' as summary")->paginate(12)->withQueryString();
app(PublicSearchMetrics::class)->candidate($request, 'partnerships', $results->total(), $filters);
if ($request->expectsJson()) {
return response()->json($results);
}
@@ -129,23 +115,16 @@ class PartnershipExchangeController extends Controller
public function dashboard(Request $request): Response
{
$actor = $request->user();
$exchange = app(PartnershipExchange::class);
$organizations = $exchange->readableOrganizations($actor);
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$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')
->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))
->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()
->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];
});
->select(['i.id', 'i.partnership_id', 'i.status', 'i.decision_reason', 'p.title', 'room.id as room_id'])->orderByDesc('i.id')->limit(100)->get();
$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,
'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
@@ -158,10 +137,7 @@ class PartnershipExchangeController extends Controller
$origins = [['type' => $source['type'], 'target_id' => $source['target_id']]];
}
$payload = $record ? json_decode($record->payload, true) : [];
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,
return Inertia::render('partnerships/editor', ['record' => $record ? [...(array) $record, 'payload' => json_decode($record->payload, true)] : null, 'origins' => $origins,
'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);
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
@@ -183,23 +159,17 @@ class PartnershipExchangeController extends Controller
$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();
$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, $request, $commands) {
$interestData = $interests->map(function ($interest) use ($record) {
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')];
});
$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,
'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,
'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,
'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,
'publicationActions' => app(PartnershipPublication::class)->actions($request->user(), $record),
'publicationCommands' => app(PartnershipPublication::class)->commands($request->user(), $record),
'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']) : [],
...$this->catalogues($request)];
if ($request->user()) {
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);
}
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
{
$data = $request->validate(['client_key' => 'nullable|uuid', '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);
$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']);
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
{
$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
{
$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']);
$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);
$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'] ?? []);
return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version, 'replayed' => (bool) ($interest->command_replayed ?? false)]) : 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);
return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version]) : back()->with('success', 'Candidatura atualizada.');
}
public function room(Request $request, int $id): Response|JsonResponse
{
$context = app(PartnershipExchange::class)->room($request->user(), $id);
$participants = app(PartnershipExchange::class)->participants($context['partnership'], $context['interest']);
$participantIds = array_column($participants, 'id');
$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]),
$props = [...$context, 'room' => [...(array) $context['room'], 'agreement' => $context['room']->agreement ? json_decode($context['room']->agreement, true) : null],
'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']),
'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))
->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')
->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]),
'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];
'milestones' => DB::table('partnership_milestones')->where('room_id', $id)->orderBy('id')->get(),
'canWrite' => app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest'])];
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']);
DB::transaction(function () use ($request, $id, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id);
abort_unless(app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest']), 403);
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$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']];
$existing = DB::table('partnership_messages')->where($key)->first();
if ($existing) {
@@ -295,7 +229,6 @@ class PartnershipExchangeController extends Controller
return;
}
abort_if(in_array($locked->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
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]);
});
@@ -305,31 +238,100 @@ class PartnershipExchangeController extends Controller
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
{
$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']);
if (isset($data['client_key'])) {
$data['client_key'] = strtolower($data['client_key']);
}
$result = app(PartnershipMilestones::class)->save($request->user(), $id, $data, $milestone);
DB::transaction(function () use ($request, $id, $milestone, $data) {
$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, ['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
{
$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');
abort_unless($file instanceof UploadedFile, 422);
$result = app(PartnershipRoomUploads::class)->save($request->user(), $id, $file, (bool) $data['shared'], isset($data['client_key']) ? strtolower($data['client_key']) : null);
$documentId = (string) Str::uuid();
$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
@@ -361,7 +363,7 @@ class PartnershipExchangeController extends Controller
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();
}
@@ -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']),
'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']) : []];
@@ -15,8 +15,6 @@ use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement;
use App\Services\PortalAudit;
use App\Services\PortalProfileAccess;
use App\Services\ScopedPortalAdministration;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -57,38 +55,32 @@ class PortalManagementController extends Controller
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
{
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403);
$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]);
$data = ['kind' => 'interest', 'requests' => $rows, 'analysts' => $this->interestAssignees()->get(['id', 'name'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
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'])
->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'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
}
public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(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.']);
DB::transaction(function () use ($request, $interest, $data) {
$row = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
if (! hash_equals($this->interestAssignmentEtag($row), $data['etag'])) {
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);
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']]);
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.']);
DB::transaction(function()use($request,$interest,$data){
$row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
if (!hash_equals($this->interestAssignmentEtag($row),$data['etag'])) 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);
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(\App\Services\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
@@ -103,7 +95,7 @@ class PortalManagementController extends Controller
$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',
]);
app(ScopedPortalAdministration::class)->interestQuery($query, $request->user());
app(\App\Services\ScopedPortalAdministration::class)->interestQuery($query, $request->user());
if ($country = $filters['country'] ?? null) {
$values = app(CountryCatalogue::class)->filterValues($country);
$query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values);
@@ -150,7 +142,7 @@ class PortalManagementController extends Controller
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)
->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
->map(function ($row) {
@@ -178,7 +170,7 @@ class PortalManagementController extends Controller
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([
'status' => ['required', Rule::in(self::STATES)],
'note' => ['nullable', 'string', 'min:10', 'max:2000'],
@@ -206,7 +198,7 @@ class PortalManagementController extends Controller
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']]);
$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
{
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']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
@@ -309,8 +301,8 @@ class PortalManagementController extends Controller
}
}
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
$sector = Sector::whereKey($filters['sector_id'])->first();
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
$sector = Sector::find($filters['sector_id']);
$query->whereIn('sector_id', $sector ? Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
return Inertia::render('management/catalog', [
@@ -327,7 +319,7 @@ class PortalManagementController extends Controller
public function edit(string $resource, ?int $id = null): Response
{
$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', [
'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.']);
}
$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' ? [
'name' => ['required', 'string', 'max:150'],
'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)],
@@ -2,14 +2,11 @@
namespace App\Http\Controllers;
use App\Exceptions\OpportunityWorkflowUnavailable;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityContacts;
use App\Services\OpportunityManagement;
use App\Services\OpportunityTechnicalSupport;
use App\Services\OpportunityWorkflowExecution;
use App\Services\OpportunityProfileWorkflow;
use App\Services\PortalInternalDesignation;
use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration;
@@ -51,15 +48,9 @@ class PortalProfileController extends Controller
{
$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(),
'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,
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
'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);
}
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());
} 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');
}
$record = $service->transition($request->user(), $id, $request->all());
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 Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
class PortalProfileInstallationController extends Controller
{
@@ -23,19 +22,6 @@ class PortalProfileInstallationController extends Controller
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
{
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
{
$plan = $installation->bootstrapPreview($request->user(), $id);
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/bootstrap-confirmation', $plan);
}
@@ -4,21 +4,12 @@ namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\DocumentManagementAccess;
use App\Services\EconomicReferenceCatalog;
use App\Services\EditorialReading;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use App\Services\InvestmentGeography;
use App\Services\LegacyContent;
use App\Services\Malware\MalwareScanner;
use App\Services\PortalProfileAccess;
use App\Services\PublicDocumentPresentation;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Database\Query\Builder;
@@ -26,7 +17,6 @@ use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
@@ -35,7 +25,6 @@ use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
class PortalServicesController extends Controller
{
@@ -80,7 +69,7 @@ class PortalServicesController extends Controller
]);
$props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()];
if ($page === 'sectors') {
$props['sectors'] = SectorOrganization::catalog();
$props['sectors'] = \App\Services\SectorOrganization::catalog();
}
if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources();
@@ -126,9 +115,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',
'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
])->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;
app(PublicSearchMetrics::class)->candidate($request, 'documents', $pageResult->total(), $filters);
$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['issuers'] = (clone $this->resources())->whereNotNull('d.issuer')->distinct()->orderBy('d.issuer')->pluck('d.issuer');
@@ -155,7 +143,6 @@ class PortalServicesController extends Controller
$query->where('title', 'ilike', '%'.$filters['q'].'%');
}
$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 (app(VirtualDesk::class)->installed()) {
@@ -200,7 +187,7 @@ class PortalServicesController extends Controller
}
$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']);
$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]);
}
@@ -278,7 +265,7 @@ class PortalServicesController extends Controller
abort_unless($resource !== null, 404);
$resource->territory_name = $resource->territory_id
? 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');
$backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//')
? $back : '/documentos';
@@ -287,7 +274,7 @@ class PortalServicesController extends Controller
'resource' => $resource,
'backUrl' => $backUrl,
'downloadUrl' => route('services.download', ['id' => $id]),
'articleUrl' => $this->articleUrl($id),
'articleUrl' => Schema::hasColumn('portal_resources','editorial_content') && DB::table('portal_resources')->where('id',$id)->whereNotNull('editorial_content')->exists() ? '/artigos/'.$id : null,
]);
}
@@ -301,7 +288,7 @@ class PortalServicesController extends Controller
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'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
@@ -310,15 +297,14 @@ class PortalServicesController extends Controller
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']);
$access = app(DocumentManagementAccess::class);
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
$documents = $access->documents($request->user())->where('status', '!=', 'deleted');
$health = $scanner->health();
$health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM);
$editorial = $access->editorial($request->user());
$resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at')
$resources = $editorial ? $access->editorialResources($request->user())->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();
$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');
@@ -326,9 +312,7 @@ class PortalServicesController extends Controller
$resource->links = $links->get($resource->id, collect())->values();
$resource->revisions = $revisions->get($resource->id, collect())->values();
});
return Inertia::render('portal/manage', [
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'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(),
@@ -347,7 +331,7 @@ class PortalServicesController extends Controller
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']);
$disclosureAuthorizationData = $disclosureAuthorization ? json_decode($disclosureAuthorization->after_data, true) : null;
$resources = $this->filterManagedResources(DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at'), $managementFilters)->orderByDesc('r.updated_at')->limit(200)
$resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->orderByDesc('r.updated_at')->limit(200)
->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']);
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
@@ -358,7 +342,6 @@ class PortalServicesController extends Controller
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
return Inertia::render('portal/manage', ['resources' => $resources,
'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request),
'requirementCatalogue' => $this->requirementEvidenceEnabled()
? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values()
: collect(),
@@ -384,47 +367,6 @@ class PortalServicesController extends Controller
'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
{
if (! empty($filters['q'])) {
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
$query->where(fn ($q) => $q->where('r.title', 'ilike', $term)->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)->orWhere('d.file_name', 'ilike', $term));
}
if (! empty($filters['state'])) {
$query->where('r.published', $filters['state'] === 'published');
if ($filters['state'] !== 'published') {
$method = $filters['state'] === 'withdrawn' ? 'whereExists' : 'whereNotExists';
$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 array<array-key, mixed>
*/
private function contentAssociations(Request $request): array
{
$access = app(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')
: ($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();
return [
'sector' => 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']),
'opportunity' => app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->orderBy('title')->get(['id as value', 'title as label']),
'document' => $documents,
'procedure' => $documents->where('document_type', 'procedure')->values(),
'stage' => collect(Config::array('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(),
];
}
public function upload(Request $request): RedirectResponse
{
$this->validateManagement($request, ['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text'.(app(LegacyContent::class)->available() ? ',application/json' : ''), 'max:10240'],
@@ -457,7 +399,7 @@ class PortalServicesController extends Controller
ScanDocumentForMalware::dispatch($id)->afterCommit();
return to_route('services.manage', array_filter(['intent' => in_array($request->input('intent'), ['document', 'legislation'], true) ? $request->input('intent') : null]))->with('uploaded_document_id', $id)->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
return to_route('services.manage')->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
}
public function resource(Request $request): RedirectResponse
@@ -466,7 +408,7 @@ class PortalServicesController extends Controller
$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();
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);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
@@ -495,11 +437,11 @@ class PortalServicesController extends Controller
DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404);
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_if((int) $resource->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();
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);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
@@ -528,7 +470,7 @@ class PortalServicesController extends Controller
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())) {
abort_unless($type === 'resources', 403);
$access->assertEditableResource($request->user(), $id);
@@ -540,9 +482,7 @@ class PortalServicesController extends Controller
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
if ($type === 'resources') {
app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
}
if ($type === 'resources') app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
if ($type === 'resources' && $data['published']) {
$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',
@@ -723,7 +663,7 @@ class PortalServicesController extends Controller
'data_classification' => ['nullable', 'in:internal,public'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'],
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source,sector,opportunity'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source'.($this->requirementEvidenceEnabled() ? ',requirement' : '')],
'associations.*.target_key' => ['required', 'string', 'max:180'],
'associations.*.review_status' => ['nullable', 'in:pending,confirmed'],
'associations.*.relationship' => ['required', 'in:applies_to,supports,version_of,translation_of,amends,repeals,implements,cites,related_to'],
@@ -855,6 +795,17 @@ class PortalServicesController extends Controller
&& 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> */
private function territoryScopeIds(string $id): array
{
@@ -878,23 +829,4 @@ SQL, [$id]);
return $ids ?: [$id];
}
private function articleUrl(int $id): ?string
{
if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
return null;
}
$resource = DB::table('portal_resources')->where('id', $id)->first();
if (! $resource) {
return null;
}
if ($resource->editorial_content !== null) {
return '/artigos/'.$id;
}
try {
return app(EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null;
} catch (HttpException) {
return null;
}
}
}
@@ -1,34 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\ProactiveSupply;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ProactiveSupplyController extends Controller
{
public function review(Request $request, ProactiveSupply $service): JsonResponse|Response
{
$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;
$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');
}
}
@@ -1,124 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Procurement;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class ProcurementController extends Controller
{
private function query(Procurement $service): Builder
{
return $service->publicQuery();
}
public function index(Request $request, Procurement $service): Response
{
$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 !== '');
$query = $service->publicQuery($filters);
$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)]);
$facets = $this->query($service)->get(['v.payload'])->map(fn ($r) => json_decode($r->payload, true));
$options = [];
foreach (['entity' => ['organization_id', 'organization_name'], 'sector' => ['sector_id', 'sector_name'], 'territory' => ['territory_id', 'territory_name'], 'modality' => ['modality', 'modality'], 'financier' => ['financier', 'financier']] as $name => [$value,$label]) {
$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 array<array-key, mixed>
*/
private function summary(Procurement $service, string $vid): array
{
$v = $service->publishedVersion($vid);
return ['publication' => $v, 'state' => $service->state($v)];
}
public function show(Request $request, string $id, Procurement $service): Response
{
return $this->detail($request, $id, $service, false);
}
public function print(Request $request, string $id, Procurement $service): Response
{
return $this->detail($request, $id, $service, true);
}
private function detail(Request $request, string $id, Procurement $service, bool $print): Response
{
$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']);
return tap(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)),
'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'));
}
public function manage(Request $request, Procurement $service): Response
{
$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'));
}
public function edit(Request $request, Procurement $service, ?string $id = null): Response
{
$service->guard($request->user());
$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,
'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'));
}
public function save(Request $request, Procurement $service, ?string $id = null): RedirectResponse
{
$id = $service->save($request->user(), $id, $request->all());
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
{
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
{
$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.');
}
public function watch(Request $request, string $id, Procurement $service): RedirectResponse
{
$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()]);
return back()->with('success', 'Concurso guardado para acompanhamento. Isto não apresenta uma proposta.');
}
public function unwatch(Request $request, string $id, Procurement $service): RedirectResponse
{
abort_unless($service->available(), 404);
DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete();
return back();
}
public function watched(Request $request, Procurement $service): Response
{
$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'));
}
}
@@ -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\Models\ExistingBusiness;
use App\Models\Sector;
use App\Services\BusinessProximitySearch;
use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessClassification;
use App\Services\ExistingBusinessDrafts;
use App\Services\InvestmentGeography;
use App\Services\ExistingBusinessClassification;
use App\Services\OpportunityClassification;
use App\Services\PublicBusinessLocations;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use App\Services\InvestmentGeography;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -33,7 +28,13 @@ class PublicBusinessOpportunityController extends Controller
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$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'])) {
$term = trim($filters['q']);
@@ -42,35 +43,23 @@ class PublicBusinessOpportunityController extends Controller
->orWhereLike('existing_businesses.objective', '%'.$term.'%'));
}
if (! empty($filters['sector'])) {
$sector = Sector::whereKey($filters['sector'])->first();
$query->whereIn('existing_businesses.sector_id', $sector ? Sector::whereIn('slug', SectorOrganization::slugs($sector->slug))->pluck('id') : []);
$sector = \App\Models\Sector::find($filters['sector']);
$query->whereIn('existing_businesses.sector_id', $sector ? \App\Models\Sector::whereIn('slug', \App\Services\SectorOrganization::slugs($sector->slug))->pluck('id') : []);
}
if (! empty($filters['modality'])) {
$query->where('existing_businesses.modality', $filters['modality']);
}
$coverage = null;
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]);
$results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->appends($filters));
if ($request->expectsJson()) {
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(),
'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
{
return $request->validate([
@@ -78,7 +67,6 @@ class PublicBusinessOpportunityController extends Controller
'modality' => ['nullable', Rule::in(ExistingBusinessDrafts::MODALITIES)],
'sector' => ['nullable', 'integer', 'min:1'],
'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'],
'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
...app(ExistingBusinessClassification::class)->filterRules(),
@@ -94,18 +82,8 @@ class PublicBusinessOpportunityController extends Controller
}
$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),
'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),
'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]);
}
@@ -127,14 +105,14 @@ class PublicBusinessOpportunityController extends Controller
/** @return Builder<ExistingBusiness> */
private function publishedQuery(): Builder
{
$publicLocations = app(PublicBusinessLocations::class)->primary()
->leftJoin('geo_locations as province', 'province.id', '=', 'l.province_id')
->leftJoin('geo_locations as district', 'district.id', '=', 'l.district_id')
->leftJoin('geo_locations as locality', 'locality.id', '=', 'l.locality_id')
$publicLocations = DB::table('existing_business_locations as locations')
->leftJoin('geo_locations as province', 'province.id', '=', 'locations.province_id')
->leftJoin('geo_locations as district', 'district.id', '=', 'locations.district_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')
->select('l.existing_business_id', 'l.updated_at as location_recorded_at', 'province.name as province', 'province.id as similar_province_id')
->selectRaw(app(PublicBusinessLocations::class)->sourceProjection())
->selectRaw("CASE WHEN l.precision_level IN ('district','admin_post','locality','exact') THEN district.name ELSE NULL END AS district")
->select('locations.existing_business_id', 'province.name as province')
->selectRaw("CASE WHEN locations.precision_level IN ('district','admin_post','locality','exact') THEN district.name ELSE NULL END AS district")
->selectRaw('NULL::text AS locality');
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.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',
'public_location.province', 'public_location.district', 'public_location.locality', 'public_location.similar_province_id',
'public_location.location_recorded_at', 'public_location.location_source_reference', 'public_location.location_source_date']);
'public_location.province', 'public_location.district', 'public_location.locality']);
}
}
@@ -2,27 +2,17 @@
namespace App\Http\Controllers;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class PublicGeographyController extends Controller
{
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(['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']);
if (! empty($data['corridor'])) {
$corridors = app(InvestmentCorridorContext::class);
if ($data['type'] === 'province') {
$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.');
}
}
if (! empty($data['id'])) {
$query->where('id', $data['id']);
}
@@ -39,6 +29,6 @@ class PublicGeographyController extends Controller
->orWhereRaw('source_code ILIKE ?', [$term]));
}
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code'])->withQueryString());
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code']));
}
}
@@ -6,8 +6,6 @@ use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\SectorInvestment;
use App\Services\SectorOrganization;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -21,17 +19,15 @@ class PublicPortalController extends Controller
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)) {
return redirect(SectorOrganization::href($slug), 301);
}
if ($slug === 'mercado-financeiro/fintech') {
$slug = 'fintech';
}
if (in_array($slug, ['energia', 'fintech'], true)) return redirect(\App\Services\SectorOrganization::href($slug), 301);
if ($slug === 'mercado-financeiro/fintech') $slug = 'fintech';
$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', 'sector' => $sector->only(['id', 'name', 'slug', 'description', 'short_description']),
'sectorEntry' => array_key_exists($sector->slug, SectorInvestment::CONTENT) ? app(SectorInvestment::class)->publicView($sector) : null,
'opportunities' => \App\Services\SectorOrganization::opportunities($sector)->latest('published_at')->limit(12)->get(['id', 'title', 'slug', 'province', 'summary'])]);
}
public function opportunity(string $slug): Response
@@ -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
namespace App\Http\Controllers;
use App\Services\SectorReconciliationExecution;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
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);
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');
}
@@ -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');
}
}
+50 -180
View File
@@ -7,13 +7,9 @@ use App\Models\InvestmentSubmission;
use App\Models\User;
use App\Services\Ai\InvestorGuidance;
use App\Services\CountryCatalogue;
use App\Services\DeskAttachments;
use App\Services\DeskPaymentEvidence;
use App\Services\DeskServiceAvailability;
use App\Services\InstitutionalLinkMonitor;
use App\Services\InvestmentGeography;
use App\Services\InvestorContextConsumers;
use App\Services\ScopedPortalAdministration;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
@@ -25,12 +21,10 @@ use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
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']);
$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'])))
&& (! ($filters['theme'] ?? '') || $s['definition']['theme'] === $filters['theme']) && (! ($filters['profile'] ?? '') || $s['definition']['profile'] === $filters['profile']))->values();
$page = max(1, (int) $request->query('page', 1));
@@ -53,7 +47,7 @@ class VirtualDeskController extends Controller
$actor = $request->user();
$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']);
$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']);
$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']),
@@ -78,23 +72,6 @@ class VirtualDeskController extends Controller
}
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);
$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')
->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,
'owner' => $owner, 'canWrite' => ! $case->closed_at && $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,
'owner' => $owner, 'canWrite' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'),
'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'),
'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];
return $data;
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
}
/** @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')
->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']);
}
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']);
->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']);
}
/** @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)]);
}
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
{
$case = $desk->stage($request->user(), $id, $request->all());
@@ -189,8 +119,8 @@ class VirtualDeskController extends Controller
public function createRequest(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['service_version_id' => 'required|uuid', 'client_key' => 'required|uuid', 'opportunity' => 'nullable|string|max:100']);
$row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key'], $data['opportunity'] ?? null);
$data = $request->validate(['service_version_id' => 'required|uuid', 'client_key' => 'required|uuid']);
$row = $desk->createRequest($request->user(), $id, $data['service_version_id'], $data['client_key']);
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201) : redirect('/balcao-virtual/pedidos/'.$row->id);
}
@@ -215,21 +145,17 @@ class VirtualDeskController extends Controller
}
}
$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)],
'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),
'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(ScopedPortalAdministration::class)->deskDecision($request->user(), $row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'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, $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() : [],
'owner' => $desk->owns($request->user(), $case), 'manager' => app(\App\Services\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, []),
'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(),
'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']),
'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)]),
'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']) : [],
'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
{
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.
$data = ['requests' => DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled', 'closed'])
->whereExists(fn ($q) => $q->selectRaw('1')->from('desk_cases')->whereColumn('desk_cases.id', 'desk_requests.case_id')->whereNull('desk_cases.closed_at'))
->orderBy('created_at')->get(['id', 'protocol', 'status', 'version', 'analyst_id', 'due_at']),
'analysts' => User::whereNotNull('email_verified_at')->orderBy('name')->get(['id', 'name'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
$data=['requests'=>DB::table('desk_requests')->whereNotIn('status',['draft','cancelled','closed'])->orderBy('created_at')->get(['id','protocol','status','version','analyst_id','due_at']),
'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
{
abort_unless(app(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.']);
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.']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = DB::table('desk_requests')->where('id', $id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
$case = DB::table('desk_cases')->where('id', $row->case_id)->lockForUpdate()->first();
abort_unless($case !== null, 404);
$desk->assertOpen($case);
if ((int) $row->version !== (int) $data['version']) {
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.']);
}
$row = DB::table('desk_requests')->where('id',$id)->lockForUpdate()->first();
abort_unless($row,404);
$case = DB::table('desk_cases')->where('id',$row->case_id)->first();
abort_unless($case,404);
if ((int)$row->version !== (int)$data['version']) throw \Illuminate\Validation\ValidationException::withMessages(['version'=>'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
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.']);
$analyst = User::where('id', $data['analyst_id'])->firstOrFail();
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.');
@@ -293,8 +211,7 @@ class VirtualDeskController extends Controller
$data = $request->validate(['body' => 'required|string|min:1|max:10000', 'client_key' => 'required|uuid']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id, false, true);
$desk->assertOpen($case);
$case = $desk->case($request->user(), $row->case_id);
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);
$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');
if ($format === 'html') {
$definition = $desk->definition($row);
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()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $desk->definition($row)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
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
{
$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',
'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']);
'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']);
$key = null;
$newId = null;
$linkId = null;
$reusedLink = false;
try {
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId, &$linkId, &$reusedLink) {
$row = ($data['request_id'] ?? null) ? $desk->request($request->user(), $data['request_id'], true, true) : null;
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId) {
$case = $desk->case($request->user(), $id, true, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.');
abort_unless((int) $case->version === (int) $data['version'], 409);
if ($data['stage'] ?? null) {
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.');
}
$definition = $row ? $desk->definition($row) : [];
$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;
abort_if(DB::table('desk_documents')->where('case_id', $id)->count() >= 100, 422, 'Limite de anexos do dossier atingido.');
if ($request->hasFile('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());
$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.*']);
if (! $document) {
abort_if(DB::table('desk_documents')->where('case_id', $id)->distinct()->count('document_id') >= 100, 422, 'Limite de ficheiros distintos do dossier atingido.');
$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();
$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 ($duplicate) {
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']);
$documentId = $newId;
} 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);
$owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->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();
$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();
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) {
app(DeskAttachments::class)->checkMetadata($document, $field, 'document_id', true);
}
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_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $id, 'document_id' => $documentId, 'request_id' => $data['request_id'] ?? null,
'category' => $data['category'], 'stage' => $data['stage'] ?? null, 'valid_until' => $data['valid_until'] ?? null, 'source_reference' => $data['source_reference'], 'linked_by' => $request->user()->id]);
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,
'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);
$desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $documentId, 'source' => $data['source_reference'], 'consent' => true]);
});
} catch (\Throwable $e) {
if ($key) {
@@ -430,10 +303,7 @@ class VirtualDeskController extends Controller
ScanDocumentForMalware::dispatch($newId)->afterCommit();
}
$available = $this->documents($id, $request->user());
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.');
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Documento privado associado; consulte o estado da análise.');
}
public function download(Request $request, string $id, string $document, VirtualDesk $desk): BinaryFileResponse|\Illuminate\Http\Response
@@ -2,8 +2,6 @@
namespace App\Http\Controllers;
use App\Services\DeskServiceAvailability;
use App\Services\DeskServiceEditor;
use App\Services\VirtualDeskInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -81,22 +79,19 @@ class VirtualDeskInstallationController extends Controller
DB::select('select pg_advisory_xact_lock(261008, 2)');
$definition = config('virtual-desk.services.'.$data['code']);
$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;
}
$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',
...($effective ? ['effective_from' => $effective] : []),
'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()]);
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 $request->expectsJson() ? response()->json(['id' => $row->id, 'effective_from' => $row->effective_from ?? null], 201)
: back()->with('success', 'Serviço de preparação interna publicado'.(($row->effective_from ?? null) ? ' com entrada em vigor em '.$row->effective_from : '').'.');
return $request->expectsJson() ? response()->json(['id' => $row->id], 201) : back()->with('success', 'Serviço de preparação interna publicado.');
}
}
@@ -3,16 +3,14 @@
namespace App\Http\Controllers;
use App\Services\Ai\InvestorGuidanceEvidence;
use App\Services\DeskPaymentEvidence;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Manual operations use the same requests, documents and encrypted audit history. */
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);
}
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')
->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)))
->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.');
try {
app(VerifiedDocumentBinary::class)->respond($doc);
} catch (HttpExceptionInterface) {
abort(422, 'O original, a sua autorização ou integridade não pôde ser confirmado.');
}
$path = realpath(Storage::disk('local')->path($doc->storage_key));
$root = realpath(Storage::disk('local')->path(''));
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.');
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)]]);
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();
abort_unless($link !== null, 404);
$requestId = $link->request_id ?: ($data['request_id'] ?? 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);
$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()]);
$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,
@@ -70,58 +64,26 @@ class VirtualDeskOperationsController extends Controller
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) {
$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);
$evidence = app(DeskPaymentEvidence::class)->snapshot($case->id, $id, $data);
$case = $desk->case($request->user(), $row->case_id);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
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.
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['virtual-desk-payment:'.$data['reference']]);
$existing = DB::table('desk_payments')->where('reference', $data['reference'])->first();
if ($existing) {
$parts = explode('.', (string) $data['amount']);
$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 === ($evidence['document_id'] ?? null)
&& (! ($data['document_link_id'] ?? null) || ($saved['link_id'] ?? null) === $data['document_link_id']), 409);
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);
return;
}
$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()]);
$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);
});
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);
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, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row);
});
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']);
DB::transaction(function () use ($request, $id, $payment, $desk, $data) {
$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);
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();
abort_unless($record !== null, 404);
abort_unless((int) $record->version === (int) $data['version'], 409);
$evidence = app(DeskPaymentEvidence::class)->forRequest($id)[$payment] ?? null;
$doc = null;
if ($data['status'] === 'internal_verified') {
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);
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.');
$doc = $this->cleanDocument($case->id, $record->document_id, $id);
}
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);
@@ -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']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$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_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) {
$saved = $desk->decode($event->payload);
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
{
$actor = $request->user();
abort_unless($actor !== null, 403);
abort_unless($actor, 403);
$access = app(DocumentManagementAccess::class);
if ($access->full($actor)) {
return $next($request); // Preserve existing management controls.
@@ -1,50 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/** Environment reservation is not a portal identity or a business permission. */
class EnsureHomologationAccess
{
public function handle(Request $request, Closure $next): Response
{
if (! config('portal.homologation_only', false)) {
return $next($request);
}
$mode = config('portal.homologation_access_mode', 'reviewers');
if ($mode === 'perimeter') {
// Set only by the trusted server after its environment-access check.
// A client HTTP_INVESTMOZ_* header is deliberately not accepted.
abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS') === 'granted', 403,
'Este ambiente de homologação tem acesso reservado à Tempo.');
$response = $next($request);
$response->headers->set('Cache-Control', 'private, no-store');
$response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive');
return $response;
}
abort_unless($mode === 'reviewers', 503, 'A reserva da homologação não está configurada.');
// Compatibility until an independent perimeter is explicitly configured.
if ($request->is('login', 'logout', 'register', 'forgot-password', 'reset-password', 'reset-password/*',
'email/verify', 'email/verify/*', 'email/verification-notification', 'two-factor-challenge',
'user/confirm-password', 'user/confirmed-password-status', 'user/two-factor-*',
'settings/security', 'settings/password', 'user/password', 'passkeys/*', 'user/passkeys/*', 'up')) {
return $next($request);
}
$actor = $request->user();
if (! $actor) {
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;
}
public function handle(Request $request, Closure $next): Response
{
if (!config('portal.homologation_only', false)) return $next($request);
$mode=config('portal.homologation_access_mode', 'reviewers');
if ($mode==='perimeter') {
// Set only by the trusted server after its environment-access check.
// A client HTTP_INVESTMOZ_* header is deliberately not accepted.
abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS')==='granted',403,
'Este ambiente de homologação tem acesso reservado à Tempo.');
$response=$next($request);
$response->headers->set('Cache-Control','private, no-store');
$response->headers->set('X-Robots-Tag','noindex, nofollow, noarchive');
return $response;
}
abort_unless($mode==='reviewers',503,'A reserva da homologação não está configurada.');
// Compatibility until an independent perimeter is explicitly configured.
if ($request->is('login','logout','register','forgot-password','reset-password','reset-password/*',
'email/verify','email/verify/*','email/verification-notification','two-factor-challenge',
'user/confirm-password','user/confirmed-password-status','user/two-factor-*',
'settings/security','settings/password','user/password','passkeys/*','user/passkeys/*','up')) return $next($request);
$actor=$request->user();
if (!$actor) 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,30 +1,25 @@
<?php
namespace App\Http\Middleware;
use App\Services\DocumentManagementAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureScopedEditorial
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor !== null, 403);
$access = app(DocumentManagementAccess::class);
abort_unless($actor, 403);
$access = app(\App\Services\DocumentManagementAccess::class);
if ($access->full($actor)) {
return $next($request);
}
abort_unless($access->editorial($actor), 403);
$allowed = in_array($request->route()->getName(), ['editorial.edit', 'editorial.save', 'editorial.preview', 'editorial.preview-content', 'services.resource', 'services.resource.update'], true)
$allowed = in_array($request->route()->getName(), ['editorial.edit', 'editorial.save', 'services.resource', 'services.resource.update'], true)
|| ($request->route()->getName() === 'services.moderate' && $request->route('type') === 'resources');
abort_unless($allowed, 403);
if ($request->route('id') !== null) {
$access->assertEditableResource($actor, (int) $request->route('id'));
}
return $next($request);
}
}
@@ -14,17 +14,15 @@ final class EnsureScopedPortalAdministration
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor !== null, 403);
abort_unless($actor, 403);
$scope = app(ScopedPortalAdministration::class);
$path = $request->path();
$interests = $request->is('management/interests', 'management/interests/*', 'api/v1/investment/admin/interests/*');
// Fiscal maintenance reuses authorised administration; unrelated gates remain unchanged.
$geometryRead = $request->isMethod('GET') && $path === 'management/territory-geometries';
$catalogue = $geometryRead || $request->is('management/territory-geometry-import', 'management/territory-geometry-import/*',
'management/economic-references/lot-01', 'management/economic-references/lot-01/*',
$catalogue = $request->is('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/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');
$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/*');
@@ -53,7 +53,7 @@ final class EnsureSimulationRecoveryCookie
}
}
$response->headers->setCookie(cookie(
return $response->withCookie(cookie(
self::NAME,
$forget ? '' : $token,
$minutes,
@@ -64,8 +64,6 @@ final class EnsureSimulationRecoveryCookie
false,
'lax',
));
return $response;
}
private function isExplicitDraftActivity(Request $request, Response $response): bool
+9 -20
View File
@@ -3,14 +3,10 @@
namespace App\Http\Middleware;
use App\Models\ExistingBusiness;
use App\Services\ContentManagement;
use App\Services\FundingProgrammes;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAccess;
use App\Services\Procurement;
use App\Services\ScopedPortalAdministration;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -52,17 +48,11 @@ class HandleInertiaRequests extends Middleware
*/
public function share(Request $request): array
{
$contentManagementAccess = app(ContentManagement::class)->access($request->user());
return [
...parent::share($request),
'name' => config('app.name'),
'procurementEnabled' => app(Procurement::class)->available(),
'fundingEnabled' => app(FundingProgrammes::class)->installed(),
'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'contentManagementHref' => $contentManagementAccess !== [] ? '/gestao-conteudos' : null,
'contentManagementAccess' => $contentManagementAccess,
'portalOperator' => config('portal.operator_name'),
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
'legacyContentEnabled' => app(LegacyContent::class)->available(),
@@ -78,20 +68,19 @@ class HandleInertiaRequests extends Middleware
'create' => ['label' => 'Nova oportunidade', 'href' => '/management/opportunities/create'],
'installationHref' => '/management/opportunity-management-install',
] : null,
'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()) ? [
['label' => 'Zonas e parques', 'href' => '/management/investment/zones'],
['label' => 'Referências económicas', 'href' => '/management/economic-references/lot-01'],
['label' => 'Referências e formulários', 'href' => '/management/investment/references'],
['label' => 'Educação e competências', 'href' => '/management/education'],
['label' => 'Interesses atribuídos', 'href' => '/management/interests'],
['label' => 'Atribuições de interesses', 'href' => '/management/interests/atribuicoes'],
['label' => 'Atribuições do Balcão', 'href' => '/management/balcao/atribuicoes'],
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning')],
'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [
['label'=>'Zonas e parques','href'=>'/management/investment/zones'],
['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'],
['label'=>'Referências e formulários','href'=>'/management/investment/references'],
['label'=>'Educação e competências','href'=>'/management/education'],
['label'=>'Interesses atribuídos','href'=>'/management/interests'],
['label'=>'Atribuições de interesses','href'=>'/management/interests/atribuicoes'],
['label'=>'Atribuições do Balcão','href'=>'/management/balcao/atribuicoes'],
] : [],
'auth' => [
'user' => $request->user(),
'canManagePortal' => $request->user()?->can('manage-portal') ?? false,
'legacyManagementAccess' => $request->user()?->hasVerifiedEmail() && app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
+2 -6
View File
@@ -13,19 +13,15 @@ class PortalProfileSession
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()) {
return $next($request);
}
$access = app(PortalProfileAccess::class);
$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')
|| ($request->is('logout') && $request->isMethod('POST'));
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.');
}
if ($available) {
if ($request->user() && $available) {
$revision = (int) DB::table('users')->where('id', $request->user()->id)->value('portal_access_revision');
$bound = $request->session()->get('portal.access_revision');
if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) {
@@ -29,9 +29,7 @@ class ProtectProfileOpportunity
if ($record && ($record->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades'))) {
abort_if($record->owner_user_id === null && str_starts_with($request->path(), 'minhas-oportunidades'), 403, 'Registo legado não convertido para a política v1.');
$access = app(PortalProfileAccess::class);
// The promotional controller checks its own scoped disclosure capability,
// like workflow decisions; publication does not grant draft editing.
$access->authorize($request->user(), $request->isMethod('GET') || str_ends_with($request->path(), '/workflow') || str_ends_with($request->path(), '/preview') || $request->is('minhas-oportunidades/*/classification/review', 'minhas-oportunidades/*/divulgacao/previa', 'minhas-oportunidades/*/divulgacao/confirmar') ? 'view' : 'edit', $record);
$access->authorize($request->user(), $request->isMethod('GET') || str_ends_with($request->path(), '/workflow') || str_ends_with($request->path(), '/preview') || $request->is('minhas-oportunidades/*/classification/review') ? 'view' : 'edit', $record);
abort_if($request->is('management/opportunity-origin/apply'), 403, 'O cadastro privado v1 não usa a classificação editorial dos exemplos legados.');
abort_if($request->isMethod('POST') && str_ends_with($request->path(), '/lifecycle') && ! str_contains($request->path(), '/resources/'), 403, 'Use o workflow com decisões segregadas; o perfil não concede exclusão.');
}
@@ -13,22 +13,15 @@ class VerifiedOrSimpleParticipation
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user()->hasVerifiedEmail()) {
return $next($request);
}
if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) {
return $next($request);
}
if ($request->user()->hasVerifiedEmail()) return $next($request);
if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) return $next($request);
$id = $request->route('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');
if ($id && ($show || $message)) {
$row = InvestmentSubmission::whereKey($id)->where('user_id', $request->user()->id)->firstOrFail();
if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) {
return $next($request);
}
if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) return $next($request);
}
return app(EnsureEmailIsVerified::class)->handle($request, $next);
}
}
@@ -6,10 +6,8 @@ namespace App\Http\Requests;
use App\Models\LandAssistanceCase;
use App\Services\CountryCatalogue;
use App\Services\InvestmentConsumerScope;
use App\Services\InvestmentGeography;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Validator;
@@ -21,14 +19,7 @@ class StoreLandAssistanceCaseRequest extends FormRequest
{
$payload = $this->validated('payload');
if (! is_array($payload)) {
return [];
}
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']);
return is_array($payload) ? $payload : [];
}
public function authorize(): bool
@@ -42,10 +33,7 @@ class StoreLandAssistanceCaseRequest extends FormRequest
return [
'version' => [$this->isMethod('post') && ! $this->routeIs('land-assistance.support') ? 'nullable' : 'required', 'integer', 'min:1'],
'context_revision' => ['nullable', 'integer', 'min:0'], 'context_reviewed' => ['nullable', 'boolean'],
'payload' => ['required', 'array:context_opportunity,context_opportunity_snapshot,context_reuse,support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
'payload.context_opportunity' => ['nullable', 'string', 'max:100'],
'payload.context_opportunity_snapshot' => ['nullable', 'string', 'max:200000'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload' => ['required', 'array:support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
'payload.support_kind' => ['nullable', 'in:investment,home,family_exploitation,recognition,regularization,delimitation,change_of_use,transfer,existing_process,conflict_guidance,other'],
'payload.request_description' => ['nullable', 'string', 'max:4000'],
'payload.project_reference' => ['nullable', 'string', 'max:160'],
@@ -37,7 +37,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'context_opportunity', 'context_applicability_snapshot', 'investment_regime', 'context_reuse', 'compatible_fields_snapshot', 'published_iva_base', 'published_iva_currency', 'published_iva_date', 'published_iva_scope', 'monthly_iva_settings', 'monthly_iva_snapshot', 'modelo_a_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'monthly_iva_settings', 'monthly_iva_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale', 'project_risk_responses_snapshot', 'project_questionnaire_version',
'customer_validation_status', 'cost_estimate_status', 'location_assessment_status', 'funding_status', 'receivables_timing_status',
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
@@ -72,20 +72,8 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.business_plan_snapshot' => ['nullable', 'string', 'max:60000'],
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.cashflow_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.context_applicability_snapshot' => ['nullable', 'string', 'max:1000000'],
'economic_snapshot.context_opportunity' => ['nullable', 'string', 'max:100'],
'economic_snapshot.investment_regime' => ['nullable', 'in:general,industrial_park,zee,zfi'],
'economic_snapshot.context_reuse' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.compatible_fields_snapshot' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.published_iva_base' => ['nullable', 'string', 'max:40'],
'economic_snapshot.published_iva_currency' => ['nullable', 'string', 'max:3'],
'economic_snapshot.published_iva_date' => ['nullable', 'string', 'max:10'],
'economic_snapshot.published_iva_scope' => ['nullable', 'string', 'in:yes,no'],
'economic_snapshot.monthly_iva_settings' => ['nullable', 'string', 'max:2000'],
'economic_snapshot.monthly_iva_snapshot' => ['nullable', 'string', 'max:60000'],
// Preserve accepted books and explicit history through the L3 refund increment.
// This is a bounded transport choice, not a business limit on fiscal records.
'economic_snapshot.modelo_a_snapshot' => ['nullable', 'string', 'json', 'max:6000000'],
'economic_snapshot.project_team_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.project_execution_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.project_risk_responses_snapshot' => ['nullable', 'string', 'max:30000'],
@@ -2,8 +2,6 @@
namespace App\Http\Resources;
use App\Services\ExistingBusinessClassification;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -25,15 +23,11 @@ class PublicBusinessOpportunityResource extends JsonResource
'amount_max' => data_get($this->resource, 'amount_max'),
'available_percentage' => data_get($this->resource, 'available_percentage'),
'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'])),
'province' => $this->resource->getAttribute('province'),
'district' => $this->resource->getAttribute('district'),
'locality' => $this->resource->getAttribute('locality'),
'multidimensional' => app(ExistingBusinessClassification::class)->publicRows($this->resource),
'multidimensional' => app(\App\Services\ExistingBusinessClassification::class)->publicRows($this->resource),
];
}
}
+15 -112
View File
@@ -2,9 +2,7 @@
namespace App\Jobs;
use App\Services\Malware\DocumentScanAttempts;
use App\Services\Malware\MalwareScanner;
use App\Services\OpportunityTechnicalIncidents;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -13,7 +11,6 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
@@ -28,28 +25,7 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
public int $uniqueFor = 600;
public ?string $executionId = null;
/** @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 __construct(public readonly string $documentId) {}
public function uniqueId(): string
{
@@ -75,20 +51,11 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
if ($row->malware_scan_status === 'infected') {
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([
'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;
});
@@ -102,32 +69,22 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
$root = $disk->path('');
$path = $disk->path($document->storage_key);
$temporary = null;
$operationFailed = false;
try {
if (($document->storage_encryption ?? null) === 'laravel') {
$temporary = tempnam($root, 'scan-private-');
if ($temporary === false) {
throw new \RuntimeException('Private scan workspace unavailable');
}
if (! @chmod($temporary, 0600)) {
throw new \RuntimeException('Private scan workspace permissions unavailable');
}
$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);
chmod($temporary, 0600);
file_put_contents($temporary, Crypt::decryptString($disk->get($document->storage_key)));
$path = $temporary;
}
$beforeHash = $this->fileHash($path);
if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) {
$applied = $this->record($document, [
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'not_run',
$this->record($document, [
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'ClamAV',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null,
], $startedAt, 'inconclusive');
if ($applied) {
$this->notify('scan_inconclusive');
}
return;
}
@@ -142,29 +99,10 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
}
$result['file_sha256'] = $afterHash;
$applied = $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;
$this->record($document, $result, $startedAt, $result['result']);
} finally {
if (is_string($temporary) && is_file($temporary)) {
try {
$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');
}
}
unlink($temporary);
}
}
}
@@ -175,38 +113,11 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
if (! $document || $document->status === 'deleted' || $document->deleted_at !== null) {
return;
}
if ($document->malware_scan_status === 'pending') {
// A failure before claiming the row cannot overwrite another scan result.
$this->notify('scan_worker_failed');
return;
}
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',
$this->record($document, [
'result' => 'failed', 'reason_code' => 'scanner_unavailable_after_retries', 'engine' => 'ClamAV',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => null, 'detail' => null,
], 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
@@ -219,17 +130,12 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
}
/** @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();
if (! $locked || $locked->status === 'deleted' || $locked->deleted_at !== null) {
return null;
}
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;
return;
}
$sameBytes = hash_equals(trim($document->sha256), trim((string) $locked->sha256));
@@ -254,16 +160,13 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
'updated_at' => $finishedAt,
]);
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,
'engine' => $result['engine'] ?? 'ClamAV', 'engine_version' => $result['engine_version'] ?? 'unknown',
'signatures_at' => $result['signatures_at'] ?? null, 'started_at' => $startedAt,
'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;
/**
* @property string|null $external_id
* @property string|null $source_system
* @property string $full_name
* @property string|null $phone
*/
class Contact extends CrmRecord {}
-21
View File
@@ -3,8 +3,6 @@
namespace App\Models;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Schema;
/**
* @property string|null $lead_id
@@ -15,23 +13,4 @@ class CrmTask extends CrmRecord
protected $table = 'tasks';
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 $valid_from
* @property string|null $valid_until
* @property array<string, mixed>|null $fiscal_acceptance
*/
class InvestmentLegalSource extends Model
{
+1 -1
View File
@@ -24,7 +24,7 @@ class InvestmentZone extends DomainRecord
{
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> */
public function primaryTerritory(): BelongsTo
+2 -19
View File
@@ -2,9 +2,6 @@
namespace App\Models;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityPublicText;
use App\Services\ProactiveInvestment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -34,25 +31,12 @@ class Opportunity extends Model
protected $appends = ['maturity_catalogue_version'];
public function toArray(): array
{
$data = parent::toArray();
if (isset($data['description'])) {
$data['description'] = OpportunityPublicText::description($this, $data['description']);
}
if ($this->exists && ($proactive = app(ProactiveInvestment::class)->publicData($this))) {
$data['proactive'] = $proactive;
}
return $data;
}
public function getMaturityCatalogueVersionAttribute(): ?string
{
// Only published metadata is public; never expose the current private draft.
$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;
}
@@ -84,8 +68,7 @@ class Opportunity extends Model
$scope = $public ? 'public' : ($visible && $this->owner_user_id !== null ? 'restricted' : 'unpublished');
return ['scope' => $scope, 'public_catalog' => $public, 'label' => match ($scope) {
'public' => data_get($this->workflow_context, 'publication.purpose') === 'promotional_preparation'
? 'Resumo divulgado — Em preparação; não autoriza execução' : 'Publicada no catálogo público',
'public' => 'Publicada no catálogo público',
'restricted' => 'Disponibilizada com acesso restrito — perfis e organização autorizados',
default => 'Sem versão disponibilizada; aprovação interna é uma etapa distinta',
}];
+4 -15
View File
@@ -2,15 +2,10 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property list<array<string, mixed>> $specializations
* @property string $url
*/
class Sector extends Model
{
use SoftDeletes;
@@ -25,24 +20,18 @@ class Sector extends Model
'published_at' => 'datetime',
];
/** Presentation only: historical masters and foreign keys remain untouched.
* @param Builder<Sector> $query
* @return Builder<Sector>
*/
public function scopeChoices(Builder $query): Builder
/** Presentation only: historical masters and foreign keys remain untouched. */
public function scopeChoices($query)
{
return $query->where('is_active', true)->where('slug', '!=', 'energia');
}
/** @param Builder<Sector> $query
* @return Builder<Sector>
*/
public function scopeCatalog(Builder $query): Builder
public function scopeCatalog($query)
{
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;
}
-3
View File
@@ -11,9 +11,6 @@ use Illuminate\Support\Carbon;
* @property string $component
* @property string $scope_key
* @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|null $reviewer_id
* @property int|null $approver_id
+7 -16
View File
@@ -4,7 +4,6 @@ namespace App\Policies;
use App\Models\ExistingBusiness;
use App\Models\User;
use App\Services\ExistingBusinessProfileCategory;
use App\Services\PortalProfileAccess;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
@@ -88,13 +87,11 @@ class ExistingBusinessPolicy
return $this->designatedAny($user);
}
/** Filter before projecting or decrypting private fields in the review queue.
* @param Builder<ExistingBusiness> $query
*/
/** Filter before projecting or decrypting private fields in the review queue. */
public function scopeReviewQueue(Builder $query, User $user): void
{
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();
$query->whereKey($ids);
}
@@ -122,18 +119,13 @@ class ExistingBusinessPolicy
return false;
}
$access = app(PortalProfileAccess::class);
if ($access->has($user, 'auditor')) {
return false;
}
if ($access->has($user, 'auditor')) { return false; }
if (! $access->available()) {
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);
}
if ($business->modality !== 'company_sale') {
return false;
}
if ($business->modality !== 'company_sale') { return false; }
$responsibility = $dimension === 'regulation' ? 'legal_reviewer' : 'technical_reviewer';
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($responsibility, json_decode($g->responsibilities, true), true));
@@ -145,7 +137,6 @@ class ExistingBusinessPolicy
|| app(PortalProfileAccess::class)->has($user, 'auditor') || ! $this->designated($user, 'publishers', $business)) {
return false;
}
return ! DB::table('business_workflow_transitions')->where('existing_business_id', $business->id)
->whereIn('to_status', ['approval_l2', 'approved'])->where('actor_id', $user->id)->exists();
}
@@ -165,8 +156,8 @@ class ExistingBusinessPolicy
{
$access = app(PortalProfileAccess::class);
if ($access->available()) {
$category = $business ? ExistingBusinessProfileCategory::category($business) : null;
if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user, 'auditor')) {
$category=$business ? \App\Services\ExistingBusinessProfileCategory::category($business) : null;
if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user,'auditor')) {
return false;
}
$role = $designation === 'publishers' ? 'publisher' : 'validator';
@@ -177,7 +168,7 @@ class ExistingBusinessPolicy
$organizationMatches = $grant->organization_id === null
|| ($business !== null && $grant->organization_id === $business->organization_id);
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;
}
}
+2 -8
View File
@@ -22,8 +22,6 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\LogoutResponse;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
class AppServiceProvider extends ServiceProvider
@@ -47,12 +45,12 @@ class AppServiceProvider extends ServiceProvider
// Fortify calls this response AFTER invalidating/regenerating the server session.
// 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
{
public function toResponse($request)
{
Inertia::clearHistory();
\Inertia\Inertia::clearHistory();
return parent::toResponse($request);
}
@@ -79,10 +77,6 @@ class AppServiceProvider extends ServiceProvider
Cache::forget('portal_home_en');
Cache::forget('portal_home_v2_pt');
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::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) {
$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'])
->whereNotExists(CrmTask::availableForWork()->whereColumn('tasks.lead_id', 'leads.id')->selectRaw('1')->toBase())
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['new', 'qualified'])->whereDoesntHave('tasks', fn ($q) => $q->whereIn('status', ['open', 'in_progress']))
->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];
}
@@ -92,7 +91,7 @@ final class AssistiveRules
/** @return list<array<string, mixed>> */
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) => [
'record_type' => 'task', 'record_id' => $task->id, 'rule' => 'overdue_open_task',
'due_at' => $task->getAttribute('due_at')?->toIso8601String(), 'lead_id' => $task->lead_id,
+4 -12
View File
@@ -4,10 +4,8 @@ namespace App\Services\Ai;
use App\Models\CrmTask;
use App\Models\InvestmentSubmission;
use App\Models\Lead;
use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Services\CrmNextAction;
use App\Services\InvestmentGeography;
use App\Services\VirtualDesk;
use Illuminate\Support\Arr;
@@ -26,8 +24,8 @@ class InvestorGuidance
return Schema::hasColumn('tasks', 'desk_case_id');
}
/** Reads each linked module with its own owner rule; case membership does not grant access to a private simulator.
* @return array<string,mixed> */
/** Reads each linked module with its own owner rule; case membership does not grant access to a private simulator. */
/** @return array<string,mixed> */
public function context(User $actor, \stdClass $case): array
{
$base = $this->desk->decode($case->payload);
@@ -154,7 +152,7 @@ class InvestorGuidance
->get(['t.id', 't.title', 't.status', 't.due_at', 't.version_no', 't.desk_request_id', 't.accepted_by', 't.accepted_at', 't.assignee_user_id', 'u.name as assignee_name', 't.external_sync_status']);
return ['version' => $case->version, 'rules_version' => InvestorGuidanceRules::VERSION, 'computed_at' => now()->toIso8601String(),
...$context, 'phases' => $phases, 'tasks' => $tasks, 'actions' => $actions, 'can_write' => ! $case->closed_at && $this->desk->owns($actor, $case), 'can_update_tasks' => ! $case->closed_at,
...$context, 'phases' => $phases, 'tasks' => $tasks, 'actions' => $actions, 'can_write' => $this->desk->owns($actor, $case),
'external_ai' => ['status' => 'not_configured', 'model' => null, 'consulted' => []],
'calculator_versions' => ['business_financial_plan' => hash_file('sha256', resource_path('js/lib/business-financial-plan.ts')), 'financial_calculators' => hash_file('sha256', resource_path('js/lib/financial-calculators.ts')), 'project_dossier' => hash_file('sha256', resource_path('js/lib/project-dossier.ts'))],
'crm' => ['internal' => 'available', 'external' => 'out_of_scope', 'reason' => 'O acompanhamento usa o CRM interno existente. A sincronização externa está fora desta fase; nenhum contacto, mensagem ou documento é transmitido.']];
@@ -237,16 +235,10 @@ class InvestorGuidance
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])],
'reason' => 'required|string|min:3|max:3000', 'assignee_user_id' => 'nullable|integer|exists:users,id', 'due_at' => 'nullable|date|after:now'])->validate();
DB::transaction(function () use ($actor, $id, $taskId, $data) {
$case = $this->desk->case($actor, $id, false, true);
$this->desk->assertOpen($case);
$candidate = CrmTask::where('desk_case_id', $id)->whereKey($taskId)->firstOrFail();
// Match CRM lock order, so two response channels cannot finish each other's only replacement.
$lead = $candidate->lead_id === null ? null : Lead::withTrashed()->whereKey($candidate->lead_id)->lockForUpdate()->firstOrFail();
$case = $this->desk->case($actor, $id);
$task = CrmTask::where('desk_case_id', $id)->whereKey($taskId)->lockForUpdate()->firstOrFail();
abort_unless($task->lead_id === $candidate->lead_id, 409, 'O acompanhamento associado mudou. Recupere a tarefa actual.');
abort_unless($actor->can('manage-portal') || (int) $task->getAttribute('assignee_user_id') === $actor->id, 403);
abort_unless((int) $task->getAttribute('version_no') === (int) $data['version'], 409);
app(CrmNextAction::class)->assertMayFinish($lead, $task, $data['status']);
$updates = ['status' => $data['status'], 'version_no' => $data['version'] + 1];
if ($data['assignee_user_id'] ?? null) {
abort_unless($actor->can('manage-portal'), 403);
@@ -1,42 +0,0 @@
<?php
namespace App\Services;
use RuntimeException;
/** Public read projection authorized by Tempo; source rows and originals stay private. */
class ApprovedAdministrativeBoundaries
{
public const HASH = '06ab905fcf9b19115c3521bf81de03f37873880f7765f229b9e1f79d99df7adc';
/** @var array<string,array<string,string>>|null */
private ?array $items = null;
/** @return array<string,array<string,string>> */
public function snapshots(): array
{
if ($this->items !== null) {
return $this->items;
}
$contents = file_get_contents(base_path('docs/investmoz/unification/approved-administrative-boundaries-20261024.json'));
if ($contents === false || ! hash_equals(self::HASH, hash('sha256', str_replace("\r\n", "\n", $contents)))) {
throw new RuntimeException('Approved administrative boundary manifest changed.');
}
$manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
if (count($manifest['items']) !== 408) {
throw new RuntimeException('Exact 408-boundary scope required.');
}
$this->items = array_column($manifest['items'], null, 'id');
return $this->items;
}
public function allows(string $id, string $territoryId, string $geometryHash, string $propertiesHash): bool
{
$item = $this->snapshots()[$id] ?? null;
return $item !== null && $item['territory_id'] === $territoryId
&& hash_equals($item['geometry_sha256'], $geometryHash)
&& hash_equals($item['properties_sha256'], $propertiesHash);
}
}
-106
View File
@@ -1,106 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/** Exact replay of the 28 accepted links into the transported project identities. */
final class ApprovedAnchorTerritories
{
public const HASH = 'd2893d0a638d630435aca1890f79a8094dc58eb65bbdd65a470bb685fa075190';
/** @return array{manifest_sha256:string,projects:list<array<string,mixed>>,links:list<array<string,mixed>>} */
public function plan(): array
{
$file = base_path('docs/investmoz/unification/approved-anchor-territories-20261023.json');
$contents = is_file($file) ? file_get_contents($file) : false;
if ($contents !== false) {
$contents = str_replace("\r\n", "\n", $contents);
}
if ($contents === false || ! hash_equals(self::HASH, hash('sha256', $contents))) {
throw new RuntimeException('Reviewed territorial manifest changed.');
}
$manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
if (count($manifest['items']) !== 28) {
throw new RuntimeException('Exact 28-link scope required.');
}
$projects = [];
$links = [];
foreach ($manifest['items'] as $item) {
$project = DB::table('projects')->where('id', $item['project_id'])->whereNull('deleted_at')->first();
$dossier = DB::table('investment_dossiers')->where('id', $item['dossier_id'])->whereNull('deleted_at')->first();
if (! $project || $project->code !== $item['code'] || ! $dossier || $dossier->project_id !== $project->id
|| ! hash_equals($item['content_sha256'], hash('sha256', $dossier->content))
|| ! hash_equals($item['source_sha256'], hash('sha256', $dossier->source))) {
throw new RuntimeException('Project identity or documentary source changed: '.$item['code']);
}
$original = $item['original_relation'];
$geo = DB::table('geo_locations')->where('id', $original['geo_location_id'])->whereNull('deleted_at')->first();
foreach (['location_type', 'source_code', 'parent_id', 'data_classification'] as $field) {
if (! $geo || $item['territory'][$field] !== $geo->$field) {
throw new RuntimeException('Territory identity changed: '.$original['geo_location_id']);
}
}
$metadata = $projects[$project->id]['after_metadata'] ?? json_decode($project->metadata, true, 512, JSON_THROW_ON_ERROR);
$key = $geo->id.':'.$original['role'];
$evidence = $item['evidence'];
// Preserve the original dossier id in evidence and explicitly map the destination.
$evidence['transported_dossier_id'] = $dossier->id;
if (isset($metadata['documented_location_sources'][$key]) && $metadata['documented_location_sources'][$key] != $evidence) {
throw new RuntimeException('Existing territorial evidence differs; no overwrite: '.$item['code']);
}
$projects[$project->id] ??= ['id' => $project->id, 'code' => $project->code, 'before_version' => (int) $project->version_no, 'before_metadata' => json_decode($project->metadata, true, 512, JSON_THROW_ON_ERROR)];
$metadata['documented_location_sources'][$key] = $evidence;
$projects[$project->id]['after_metadata'] = $metadata;
$existing = DB::table('project_locations')->where('project_id', $project->id)->where('geo_location_id', $geo->id)->where('role', $original['role'])->first();
if ($existing && ($existing->deleted_at || $existing->data_classification !== 'public')) {
throw new RuntimeException('Archived or restricted territorial relation must not be revived.');
}
$identity = DB::table('project_locations')->where('id', $original['id'])->first();
if ($identity && (! $existing || $identity->id !== $existing->id)) {
throw new RuntimeException('Historical relation identity is already occupied.');
}
$links[] = ['code' => $item['code'], 'already_present' => (bool) $existing, 'row' => [...$original, 'project_id' => $project->id]];
}
return ['manifest_sha256' => self::HASH, 'projects' => array_values($projects), 'links' => $links];
}
/** Caller supplies an authenticated, backed-up transaction; no source publication changes.
* @param array{manifest_sha256:string,projects:list<array<string,mixed>>,links:list<array<string,mixed>>} $plan
* @return array<string,int>
*/
public function apply(array $plan): array
{
if (DB::transactionLevel() < 1 || $this->plan() !== $plan) {
throw new RuntimeException('Reviewed transaction and unchanged preview required.');
}
$inserted = 0;
$updated = 0;
foreach ($plan['projects'] as $project) {
if ($project['before_metadata'] == $project['after_metadata']) {
continue;
}
$count = DB::table('projects')->where('id', $project['id'])->where('version_no', $project['before_version'])->update([
'metadata' => json_encode($project['after_metadata'], JSON_THROW_ON_ERROR), 'version_no' => $project['before_version'] + 1, 'updated_at' => now(),
]);
if ($count !== 1) {
throw new RuntimeException('Concurrent project change.');
}
DB::table('audit_events')->insert(['source_system' => 'approved-anchor-territories-recovery-v1', 'external_id' => self::HASH.':'.$project['id'], 'entity_type' => 'projects', 'entity_id' => $project['id'], 'action' => 'accepted_territories_restored',
'data_classification' => 'public', 'before_data' => json_encode(['version_no' => $project['before_version'], 'metadata' => $project['before_metadata']], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['version_no' => $project['before_version'] + 1, 'manifest_sha256' => self::HASH, 'metadata' => $project['after_metadata'], 'new_approval' => false], JSON_THROW_ON_ERROR)]);
$updated++;
}
foreach ($plan['links'] as $link) {
if ($link['already_present']) {
continue;
}
DB::table('project_locations')->insert($link['row']);
$inserted++;
}
return ['relations_restored' => $inserted, 'projects_updated' => $updated, 'new_dossiers' => 0, 'publication_changes' => 0];
}
}
+6 -45
View File
@@ -15,7 +15,7 @@ final class BusinessFormSchema
'proponent_type' => ['step' => 1, 'pt' => 'Quem apresenta a oportunidade?', 'en' => 'Who is presenting the opportunity?', 'type' => 'select', 'max' => 30, 'options' => ['organization' => 'Empresa ou organização / Company or organization', 'sole_trader' => 'Empresário em nome individual / Sole trader', 'person' => 'Pessoa singular / Individual', 'public_entity' => 'Entidade pública / Public entity']],
'proponent_name' => ['step' => 1, 'pt' => 'Nome ou denominação', 'en' => 'Name', 'type' => 'text', 'max' => 200],
'country' => ['step' => 1, 'pt' => 'País de origem ou constituição', 'en' => 'Country of origin or incorporation', 'type' => 'country', 'max' => 100],
'declared_origin_country_code' => ['step' => 1, 'pt' => 'País de origem do pedido (opcional)', 'en' => 'Country from which you submit this request (optional)', 'type' => 'country', 'max' => 2],
'declared_origin_country_code' => ['step' => 1, 'pt' => 'País de origem declarado desta solicitação (opcional)', 'en' => 'Declared country of origin of this request (optional)', 'type' => 'country', 'max' => 2],
'tax_identifier' => ['step' => 1, 'pt' => 'NUIT ou identificador (interno, quando aplicável)', 'en' => 'NUIT or identifier (internal, when applicable)', 'type' => 'text', 'max' => 50],
'contact_name' => ['step' => 1, 'pt' => 'Nome da pessoa de contacto', 'en' => 'Contact person name', 'type' => 'text', 'max' => 200],
'contact_email' => ['step' => 1, 'pt' => 'Correio electrónico (indique email ou telefone)', 'en' => 'Email (provide email or phone)', 'type' => 'email', 'max' => 254],
@@ -23,8 +23,8 @@ final class BusinessFormSchema
'contact_preference' => ['step' => 1, 'pt' => 'Preferência de contacto', 'en' => 'Contact preference', 'type' => 'select', 'max' => 30, 'options' => ['through_portal' => 'Receber pedidos através da InvestMoz', 'approved_business_contact' => 'Mostrar contacto empresarial aprovado']],
'acting_capacity' => ['step' => 2, 'pt' => 'Qualidade em que actua', 'en' => 'Acting capacity', 'type' => 'select', 'max' => 30, 'options' => ['holder' => 'Titular / Holder', 'representative' => 'Representante / Representative']],
'declared_holder' => ['step' => 2, 'pt' => 'Entidade ou pessoa representada', 'en' => 'Entity or person represented', 'type' => 'text', 'max' => 200],
'mandate_reference' => ['step' => 2, 'pt' => 'Número ou referência da autorização de representação', 'en' => 'Representation authorization number or reference', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
'mandate_document_reference' => ['step' => 2, 'pt' => 'Referência do comprovativo de representação no portal', 'en' => 'Reference of representation evidence in the portal', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
'mandate_reference' => ['step' => 2, 'pt' => 'Referência do documento de autorização', 'en' => 'Authorization document reference', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
'mandate_document_reference' => ['step' => 2, 'pt' => 'Referência interna do documento de autorização (não carregue aqui)', 'en' => 'Internal authorization document reference (do not upload here)', 'type' => 'text', 'max' => 500, 'when' => ['acting_capacity' => ['representative']]],
'mandate_valid_until' => ['step' => 2, 'pt' => 'Validade do mandato', 'en' => 'Mandate expiry', 'type' => 'date', 'max' => 10, 'when' => ['acting_capacity' => ['representative']]],
'disclosure_scope' => ['step' => 2, 'pt' => 'Limites da autorização de divulgação', 'en' => 'Limits of disclosure authorization', 'type' => 'textarea', 'max' => 2000, 'when' => ['acting_capacity' => ['representative']]],
'legitimacy_declaration' => ['step' => 2, 'pt' => 'Declaro legitimidade para esta submissão', 'en' => 'I declare authority to submit', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim / Yes', 'no' => 'Não / No']],
@@ -64,7 +64,6 @@ final class BusinessFormSchema
'location_notes' => ['step' => 5, 'pt' => 'Endereço ou referência (interno)', 'en' => 'Address or reference (internal)', 'type' => 'textarea', 'max' => 2000],
'location_unknown' => ['step' => 5, 'pt' => 'Não conheço a localização exacta', 'en' => 'I do not know the exact location', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim', 'no' => 'Não'], 'when' => ['geography_scope' => ['specific', 'multiple']]],
'disclosure_precision' => ['step' => 5, 'pt' => 'Precisão de divulgação', 'en' => 'Disclosure precision', 'type' => 'select', 'max' => 30, 'options' => ['exact' => 'Localização exacta (sujeita a aprovação)', 'approximate' => 'Localização aproximada', 'administrative' => 'Apenas território administrativo'], 'when' => ['geography_scope' => ['specific', 'multiple']]],
...self::pointFields(),
'asset_notes' => ['step' => 6, 'pt' => 'Resumo dos activos e direitos declarados', 'en' => 'Declared assets and rights summary', 'type' => 'textarea', 'max' => 2000],
'source_reference' => ['step' => 7, 'pt' => 'Referência da fonte', 'en' => 'Source reference', 'type' => 'textarea', 'max' => 2000],
'source_provider' => ['step' => 7, 'pt' => 'Quem forneceu os dados?', 'en' => 'Who supplied the information?', 'type' => 'text', 'max' => 500],
@@ -80,27 +79,8 @@ final class BusinessFormSchema
];
}
/** @return array<string, array{step:int,pt:string,en:string,type:string,max:int,options?:array<string,string>,when?:array<string,list<string>>}> */
private static function pointFields(): array
{
$when = ['geography_scope' => ['specific', 'multiple'], 'disclosure_precision' => ['exact'], 'location_unknown' => ['no']];
$active = [...$when, 'location_point_enabled' => ['yes']];
return [
'location_point_enabled' => ['step' => 5, 'pt' => 'Preparar um ponto exacto para publicação e pesquisa por raio?', 'en' => 'Prepare an exact point for publication and radius searches?', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim — o ponto substituirá a localização primária na publicação aprovada', 'no' => 'Não — conservar o cadastro de localizações existente'], 'when' => $when],
'location_point_territory' => ['step' => 5, 'pt' => 'Localização já declarada a que pertence o ponto primário', 'en' => 'Previously declared location for the primary point', 'type' => 'point_territory', 'max' => 150, 'when' => [...$active, 'geography_scope' => ['multiple']]],
'location_latitude' => ['step' => 5, 'pt' => 'Latitude WGS84 (graus decimais, -90 a 90)', 'en' => 'WGS84 latitude (decimal degrees, -90 to 90)', 'type' => 'decimal', 'max' => 16, 'when' => $active],
'location_longitude' => ['step' => 5, 'pt' => 'Longitude WGS84 (graus decimais, -180 a 180)', 'en' => 'WGS84 longitude (decimal degrees, -180 to 180)', 'type' => 'decimal', 'max' => 16, 'when' => $active],
'location_point_source_mode' => ['step' => 5, 'pt' => 'Fonte das coordenadas', 'en' => 'Coordinate source', 'type' => 'select', 'max' => 20, 'options' => ['listing' => 'Reutilizar referência e data da etapa Fontes, se também sustentam este ponto', 'custom' => 'Indicar uma fonte diferente para o ponto'], 'when' => $active],
'location_point_source_reference' => ['step' => 5, 'pt' => 'Referência da fonte específica do ponto', 'en' => 'Point-specific source reference', 'type' => 'textarea', 'max' => 2000, 'when' => [...$active, 'location_point_source_mode' => ['custom']]],
'location_point_source_date' => ['step' => 5, 'pt' => 'Data dessa fonte (deixe vazio se desconhecida)', 'en' => 'Source date (leave blank if unknown)', 'type' => 'date', 'max' => 10, 'when' => [...$active, 'location_point_source_mode' => ['custom']]],
'location_point_share_source' => ['step' => 5, 'pt' => 'Divulgar também a referência da fonte do ponto?', 'en' => 'Also disclose the point source reference?', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim — referência sem contactos ou informação reservada, sujeita à publicação aprovada', 'no' => 'Não — manter a referência apenas no processo reservado'], 'when' => $active],
];
}
/** @param array<string,mixed> $payload
* @return array<string, list<string|\Closure|In>> */
public static function rules(array $payload = []): array
/** @return array<string, list<string|\Closure|In>> */
public static function rules(): array
{
$rules = [];
foreach (self::fields() as $key => $field) {
@@ -119,30 +99,11 @@ final class BusinessFormSchema
$rules['payload.investment_total'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
$rules['payload.amount_sought'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
$rules['payload.source_date'][] = 'date_format:Y-m-d';
foreach (['location_latitude', 'location_longitude'] as $coordinate) {
// Numeric rules make Laravel interpret max:16 as a numeric ceiling, not a text limit.
$rules['payload.'.$coordinate] = ['nullable', 'string', function ($attribute, $value, $fail) {
if (strlen((string) $value) > 16) {
$fail('Indique a coordenada com até 16 caracteres.');
}
}];
}
if (app(BusinessPointPublication::class)->applicable($payload)) {
if (($payload['location_point_source_mode'] ?? null) === 'custom') {
$rules['payload.location_point_source_date'][] = 'date_format:Y-m-d';
}
$rules['payload.location_latitude'][] = 'numeric';
$rules['payload.location_latitude'][] = 'between:-90,90';
$rules['payload.location_longitude'][] = 'numeric';
$rules['payload.location_longitude'][] = 'between:-180,180';
}
$rules['payload.official_source_url'][] = 'url';
// The field limit is a text length, not an 8% ceiling.
$rules['payload.equity_percentage'] = ['nullable', 'numeric', 'between:0,100',
function ($attribute, $value, $fail) {
if (strlen((string) $value) > 8) {
$fail('Indique a percentagem com até 8 caracteres.');
}
if (strlen((string) $value) > 8) $fail('Indique a percentagem com até 8 caracteres.');
}];
foreach (['valid_until', 'mandate_valid_until'] as $date) {
$rules['payload.'.$date][] = 'date_format:Y-m-d';
-125
View File
@@ -1,125 +0,0 @@
<?php
namespace App\Services;
use App\Models\ExistingBusiness;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Optional supplement of the approved listing; never grants publication authority. */
final class BusinessPointPublication
{
public function available(): bool
{
return Schema::hasColumn('existing_business_locations', 'public_source_reference');
}
/** @param array<string,mixed> $payload */
public function applicable(array $payload): bool
{
return ($payload['location_point_enabled'] ?? null) === 'yes'
&& in_array($payload['geography_scope'] ?? null, ['specific', 'multiple'], true)
&& ($payload['disclosure_precision'] ?? null) === 'exact'
&& ($payload['location_unknown'] ?? null) === 'no';
}
/** @param array<string,mixed> $payload
* @return list<array<string,mixed>> */
public function paths(array $payload): array
{
if (($payload['geography_scope'] ?? null) === 'specific') {
return [array_intersect_key($payload, InvestmentGeography::FIELDS)];
}
if (($payload['geography_scope'] ?? null) !== 'multiple') {
return [];
}
$rows = json_decode((string) ($payload['locations_json'] ?? ''), true);
return is_array($rows) && array_is_list($rows) ? array_values(array_filter($rows, 'is_array')) : [];
}
/** @param array<string,mixed> $path */
public function pathKey(array $path): string
{
return implode('|', array_map(fn ($field) => (string) ($path[$field] ?? ''), array_keys(InvestmentGeography::FIELDS)));
}
/** @param array<string,mixed> $payload
* @return array<string,mixed>|null */
public function candidate(array $payload): ?array
{
if (! $this->applicable($payload)) {
return null;
}
abort_unless($this->available(), 409, 'O suplemento de localização ainda não está instalado neste destino. O ponto não será publicado.');
$rules = [
'location_unknown' => ['required', 'in:no'],
'location_latitude' => ['required', 'numeric', 'between:-90,90'],
'location_longitude' => ['required', 'numeric', 'between:-180,180'],
'location_point_source_mode' => ['required', 'in:listing,custom'],
'location_point_share_source' => ['nullable', 'in:yes,no'],
'location_point_source_reference' => ['required_if:location_point_source_mode,custom', 'nullable', 'string', 'max:2000'],
'location_point_source_date' => ($payload['location_point_source_mode'] ?? null) === 'custom' ? ['nullable', 'date_format:Y-m-d'] : ['nullable'],
'location_point_territory' => ['required_if:geography_scope,multiple', 'nullable', 'string', 'max:150'],
];
Validator::make($payload, $rules)->validate();
$paths = $this->paths($payload);
$path = ($payload['geography_scope'] ?? null) === 'specific' ? ($paths[0] ?? null)
: collect($paths)->first(fn ($row) => $this->pathKey($row) === $payload['location_point_territory']);
if (! $path || empty($path['province_id'])) {
throw ValidationException::withMessages(['location_point_territory' => 'Seleccione uma das localizações já declaradas para o ponto primário. Não é atribuída uma localização por ordem ou por nome.']);
}
$nodes = app(InvestmentGeography::class)->selection($path);
$listingSource = $payload['location_point_source_mode'] === 'listing';
$source = $listingSource ? ($payload['source_reference'] ?? null) : ($payload['location_point_source_reference'] ?? null);
$date = $listingSource ? ($payload['source_date'] ?? null) : ($payload['location_point_source_date'] ?? null);
Validator::make(['location_point_source_reference' => $source, 'location_point_source_date' => $date], [
'location_point_source_reference' => ['required', 'string', 'max:2000'], 'location_point_source_date' => ['nullable', 'date_format:Y-m-d'],
])->validate();
return ['path' => array_combine(array_keys(InvestmentGeography::FIELDS), array_map(fn ($field) => ($path[$field] ?? null) ?: null, array_keys(InvestmentGeography::FIELDS))),
'reference_snapshot' => array_map(fn ($node) => (array) $node, $nodes),
'latitude' => (float) $payload['location_latitude'], 'longitude' => (float) $payload['location_longitude'],
'source_reference' => $source, 'source_date' => $date ?: null,
'public_source_reference' => ($payload['location_point_share_source'] ?? null) === 'yes' ? $source : null];
}
/** Called only within the existing authorized, locked publication transaction.
* @return array<string,mixed> */
public function publish(ExistingBusiness $business): array
{
$point = $this->candidate($business->payload);
if ($point === null) {
return [];
}
$previous = DB::table('existing_business_locations')->where('existing_business_id', $business->id)->where('is_primary', true)
->select('*')->selectRaw('ST_AsEWKT(geom) AS geometry_snapshot')->first();
$geometry = DB::selectOne('SELECT ST_AsEWKT(ST_SetSRID(ST_MakePoint(?::float8, ?::float8),4326)) AS value', [$point['longitude'], $point['latitude']])->value;
$values = [...$point['path'], 'precision_level' => 'exact', 'is_public' => true,
'geom' => $geometry,
'public_source_reference' => $point['public_source_reference'], 'source_date' => $point['source_date'],
'source_system' => 'portal-business-form', 'source_payload_version' => $business->version_no + 1, 'updated_at' => now()];
if ($previous) {
DB::table('existing_business_locations')->where('id', $previous->id)->update($values);
} else {
DB::table('existing_business_locations')->insert([...$values, 'id' => (string) Str::uuid(), 'existing_business_id' => $business->id, 'is_primary' => true, 'created_at' => now()]);
}
return ['point_publication' => ['previous' => $previous ? (array) $previous : null, 'used' => $point,
'business_version' => $business->version_no + 1, 'source' => 'explicit_listing_point_supplement']];
}
/** Private view, only after the listing's normal view authorization.
* @return array<string,mixed>|null */
public function registered(ExistingBusiness $business): ?array
{
$row = DB::table('existing_business_locations')->where('existing_business_id', $business->id)->where('is_primary', true)
->select('id', 'precision_level', 'is_public', 'province_id', 'district_id', 'admin_post_id', 'locality_id')
->selectRaw("geom IS NOT NULL AS has_geometry, CASE WHEN GeometryType(geom)='POINT' THEN ST_Y(geom) END AS latitude, CASE WHEN GeometryType(geom)='POINT' THEN ST_X(geom) END AS longitude")->first();
return $row ? (array) $row : null;
}
}

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