Compare commits

...
Author SHA1 Message Date
nala 98fc16a3fb docs: record installed stabilization reference and finite remaining scope 2026-10-24 11:10:51 +02:00
nala 276d833a4b test: consolidate stabilization reference and preserve accepted contour scope
tests / ci (push) Canceled after 0s
2026-10-24 11:08:35 +02:00
nala 5d45541a11 docs: receipt for installed shared map stabilization and accepted public contours 2026-10-24 10:51:31 +02:00
nala 066707ece6 fix: restore shared map access and publish accepted administrative boundary projection
tests / ci (push) Canceled after 0s
2026-10-24 10:47:13 +02:00
nala c1f4a71963 docs: record installed anchor integration and exact territorial recovery 2026-10-23 19:26:07 +02:00
nala cf2ddf21a1 fix: verify territorial recovery manifest with canonical Git line endings
tests / ci (push) Canceled after 0s
2026-10-23 19:20:25 +02:00
nala 3fd5128c62 fix: connect documentary anchors to investment preparation and recover accepted territories
tests / ci (push) Canceled after 0s
2026-10-23 19:17:15 +02:00
nala cdf8fcfba4 docs: record installed treatment scope and verified native PDF 2026-10-23 18:50:13 +02:00
nala 8036b9b071 docs: verify saved native PDF and release delivery condition
tests / ci (push) Canceled after 0s
2026-10-23 18:44:09 +02:00
nala 6985f82e9c docs: delimit anchor findings and pending native PDF delivery gate 2026-10-23 18:36:08 +02:00
nala 2856240cf4 fix: print selected requirements and translated opportunity stage states 2026-10-23 18:34:39 +02:00
nala 32144aa2f3 docs: record treatment scope QA and anchor search analysis boundary 2026-10-23 18:26:00 +02:00
nala 1afdfee8c0 fix: share selected opportunity across simulator stages and land shortcuts 2026-10-23 18:25:09 +02:00
nala bc84fbdb3e docs: delimit remaining transversal investment scope 2026-10-23 18:06:42 +02:00
nala b6a626c92d Record installed opportunity-consumer delta in homologation 2026-10-23 17:58:15 +02:00
nala 55136b446d Record directed QA proof for all four opportunity consumers
tests / ci (push) Canceled after 0s
2026-10-23 17:51:45 +02:00
nala 4a01e9e2b7 Scope parks and downstream investment requests to the treated opportunity 2026-10-23 17:49:22 +02:00
nala ad4160f040 Record second-opportunity context assessment and bounded consumer gaps 2026-10-23 17:18:55 +02:00
nala af9261809c Record installed complements delta and preserved acceptance 2026-10-23 17:14:04 +02:00
nala d8224ba84b Align public simulator entry check with integrated home layout
tests / ci (push) Canceled after 0s
2026-10-23 17:11:15 +02:00
nala 088f2c2cf4 Merge remote-tracking branch 'origin/main' into codex/investment-complements-20261023
tests / ci (push) Canceled after 0s
2026-10-23 16:35:03 +02:00
nala 603a88e1d3 Link documented investment complements to existing fiches and dossier context 2026-10-23 16:34:57 +02:00
paulomassigue 43cd0be7cb correcao de conflitos
tests / ci (push) Canceled after 0s
2026-10-23 16:03:34 +02:00
paulomassigue 89a9c3d994 mudanca o botao Tempo no hear fica Mais 2026-10-23 15:58:59 +02:00
nala 4098192a05 Record installed continuity corrections and destination delta checks 2026-10-23 15:48:52 +02:00
paulomassigue d242482042 home ajustado 2026-10-23 15:46:53 +02:00
nala 6b544548d3 Fix compressed draft saves provenance and Beira documentary continuity
tests / ci (push) Canceled after 0s
2026-10-23 15:41:37 +02:00
nala c3f7c3ad10 Record homologation product journey findings and publication status 2026-10-23 15:26:38 +02:00
nala 445aa7e69d Record consolidated adaptive portfolio installation receipt 2026-10-23 15:17:59 +02:00
nala 7c1f532134 Preserve inactive document history in adaptive reports
tests / ci (push) Canceled after 0s
2026-10-23 15:14:44 +02:00
nala 115c773162 Record QA continuity recovery after return reload and login
tests / ci (push) Canceled after 0s
2026-10-23 15:03:22 +02:00
paulomassigue ec4b2f2af2 organizacao da tela inicial 2026-10-23 14:51:34 +02:00
nala 3d877ec745 Preserve selected portfolio context through investment preparation 2026-10-23 14:35:50 +02:00
nala 6948ed62fc Consolidate adaptive preparation QA delivery and accepted evidence 2026-10-23 14:19:05 +02:00
nala 7d798579a6 Reconcile portfolio recovery evidence and integrated journey gap 2026-10-23 14:17:42 +02:00
nala 5bda74aedc Adapt land status to saved investment land need 2026-10-23 14:13:58 +02:00
nala f2150b1912 Reuse investment context as editable simulator summaries 2026-10-23 14:03:37 +02:00
nala 7ca94967f3 Adapt preparation presentation to declared applicability 2026-10-23 13:53:02 +02:00
nala e1f010f941 fix: preserve corridor filters and keep investment previews readable
tests / ci (push) Canceled after 0s
2026-10-23 13:30:10 +02:00
nala 0025309b98 feat: adapt investment territories to Tempo corridor configuration
tests / ci (push) Canceled after 0s
2026-10-23 13:24:58 +02:00
nala a0e80db4ec docs: confirm opportunity preparation continuity in homologation
tests / ci (push) Canceled after 0s
2026-10-23 12:31:36 +02:00
nala 7226287526 fix: keep opportunity preparation focused on the pending selection
tests / ci (push) Canceled after 0s
2026-10-23 12:29:26 +02:00
nala f1ac82ae4d docs: record clean investor entry verified in homologation
tests / ci (push) Canceled after 0s
2026-10-23 12:20:28 +02:00
nala 98474777f6 fix: remove internal metadata from investor dossier presentation
tests / ci (push) Canceled after 0s
2026-10-23 12:18:25 +02:00
nala 3848f29bea docs: record corridor catalogue verification in homologation
tests / ci (push) Canceled after 0s
2026-10-23 12:12:05 +02:00
nala 203492997e feat: explore the public opportunity catalogue by corridor
tests / ci (push) Canceled after 0s
2026-10-23 12:08:33 +02:00
nala 165c2f6171 fix: keep internal agribusiness delivery notes out of portal presentation
tests / ci (push) Canceled after 0s
2026-10-23 11:22:39 +02:00
nala 4ad1bf402b docs: record authenticated publication of 34 agribusiness opportunities
tests / ci (push) Canceled after 0s
2026-10-23 11:12:45 +02:00
nala e12ef6ad42 Record installed agribusiness workflow, preserved destination and pending real-account publication
tests / ci (push) Canceled after 0s
2026-10-23 11:00:30 +02:00
nala c641b267ac Publish agribusiness lot through scoped management and extend existing dossier for multiple opportunities
tests / ci (push) Canceled after 0s
2026-10-23 10:49:50 +02:00
paulomassigue e2b0fb4b2d melhorias de home 1 2026-10-23 10:36:34 +02:00
nala 7d08c2c664 Register 34 private agribusiness opportunities with promotional management and investor paths in QA 2026-10-23 10:15:41 +02:00
nala 761035aefb Record homologation agribusiness installation and preserved QA evidence
tests / ci (push) Canceled after 0s
2026-10-23 09:58:13 +02:00
nala 20a4099d10 Present 34 agribusiness opportunities in existing corridor sections
tests / ci (push) Canceled after 0s
2026-10-23 09:56:22 +02:00
nala e03de682ca Preserve completed features and restore approved portfolio visibility
tests / ci (push) Canceled after 0s
2026-10-23 09:19:25 +02:00
nala 37a8eb015a docs: record private legacy registration and authenticated editorial access
tests / ci (push) Canceled after 0s
2026-10-22 23:55:57 +02:00
nala 65eddf6bc7 merge: install reviewed private legacy editorial workflow
tests / ci (push) Canceled after 0s
2026-10-22 23:40:27 +02:00
nala fb4e7d8970 Complete private legacy article editing and previews in QA 2026-10-22 23:36:07 +02:00
nala bc93ccdb9e docs: reconcile all legacy articles and verify private QA batches 2026-10-22 23:10:21 +02:00
nala 7ccf62adec docs: record authenticated tender installation and final links
tests / ci (push) Canceled after 0s
2026-10-22 22:54:59 +02:00
nala f4d0970917 merge: install reviewed tender publishing in homologation
tests / ci (push) Canceled after 0s
2026-10-22 22:49:18 +02:00
nala e46f973159 docs: plan staged tender schema installation and activation 2026-10-22 22:44:42 +02:00
nala 1bcf34c0c0 feat: add QA tender and announcement publishing workflow 2026-10-22 22:41:28 +02:00
nala ef0c2acd36 docs: record authenticated homologation geometry restoration and access proof
tests / ci (push) Canceled after 0s
2026-10-22 22:04:32 +02:00
nala 30aed7cd12 Merge approved administrative access for geometry restoration
tests / ci (push) Canceled after 0s
2026-10-22 21:55:48 +02:00
nala 95d4cf44d7 fix: reuse approved administration for authenticated geometry import 2026-10-22 21:55:36 +02:00
nala 73c046e104 Merge territorial demarcation recovery reviewed for homologation
tests / ci (push) Canceled after 0s
2026-10-22 21:45:58 +02:00
nala 15d82611b6 fix: recover territorial demarcation QA and prepare retained geometry restoration 2026-10-22 21:43:54 +02:00
nala 8286d7504d fix: preserve approved editorial access independently of technical reservation
tests / ci (push) Canceled after 0s
2026-10-22 21:03:11 +02:00
nala aa2b3d05b0 fix: route authorised content staff through management access entry
tests / ci (push) Waiting to run
2026-10-22 21:00:05 +02:00
nala 97d51db60a feat: add unified team content management entry in QA
tests / ci (push) Canceled after 0s
2026-10-22 20:55:08 +02:00
Nelson_Matsinhe a1b553372e ajuste do tamanho da navbar
tests / ci (push) Canceled after 0s
2026-10-22 15:26:56 +02:00
nala f80d30f478 docs: record Tempo approval for catalogue selector integration
tests / ci (push) Canceled after 0s
2026-10-22 14:36:36 +02:00
nala 916819eb35 docs: record keyboard selector proof and mouse observation limit 2026-10-22 14:33:59 +02:00
nala 1d921c9d03 fix: keep public catalogue selectors independent of opportunity results 2026-10-22 14:28:40 +02:00
nala 7b1c35793c Fix Node navigation import and use Tempo commercial footer contact
tests / ci (push) Canceled after 0s
2026-10-22 13:54:20 +02:00
nala 1ec318561c docs: record authorised Inhambane preparation publication
tests / ci (push) Canceled after 0s
2026-10-22 13:47:50 +02:00
nala 9ac623a433 fix: preserve territorial search across investment catalogues 2026-10-22 12:52:52 +02:00
nala 5f4eb5cfaa Record authorized adaptive path deployment and enabled destination state 2026-10-22 10:18:38 +02:00
nala 042ffbde44 Preserve legacy draft shape when no compatible context is inherited
tests / ci (push) Canceled after 0s
2026-10-22 10:14:46 +02:00
nala 064296e1fb Record current v4 report proof and adaptive path integration dependencies
tests / ci (push) Canceled after 0s
2026-10-22 10:06:08 +02:00
nala a1fbda06ad Wire grounded private investment preparation into QA dossier path 2026-10-22 10:00:23 +02:00
nala 52fbb1468a Close context increment review and ground first adaptive investment path 2026-10-22 09:25:14 +02:00
nala a76369eb86 Preserve dossier context and expose contextual applicability in QA 2026-10-22 09:16:15 +02:00
nala 57fc914821 Merge verified IVA monthly projection and Modelo A interface into homologation
tests / ci (push) Canceled after 0s
2026-10-21 14:34:47 +02:00
nala 0b0cf76137 Reconcile IVA projection with Tempo Modelo A in QA 2026-10-21 14:27:24 +02:00
nala 3f5430c3ce Integrate monthly IVA with projected sales in QA 2026-10-21 14:05:46 +02:00
nala 0d7b70459d fix: explain IVA query conditions and distinguish integration failures 2026-10-21 13:35:22 +02:00
paulomassigue f3ec686f8c diminuicao do tamanho do footer
tests / ci (push) Canceled after 0s
2026-10-21 13:16:35 +02:00
nala bd5166bd11 docs: record authorised fiscal rule publication and consumer activation 2026-10-21 12:57:40 +02:00
paulomassigue d3fe8a02ec Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
2026-10-21 12:46:54 +02:00
paulomassigue f5b1d67ee5 remocao de inicio em descoberta 2026-10-21 12:46:26 +02:00
paulomassigue 90179149c7 Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2 2026-10-21 12:42:05 +02:00
paulomassigue 364be835fe correcao de conflitos 2026-10-21 12:41:18 +02:00
nala 4a4d121275 fix: allow authorised fiscal maintenance by the same responsible person
tests / ci (push) Canceled after 0s
2026-10-21 12:39:07 +02:00
paulomassigue 480effa5fc separacao de sobre nos no dropdow Tempo 2026-10-21 12:36:08 +02:00
nala 226fd308dd feat: connect published IVA consumer to normal simulator in QA
tests / ci (push) Canceled after 0s
2026-10-21 11:59:50 +02:00
nala 2ced7b790c docs: delimit published IVA consumer and three rollout operations 2026-10-21 11:39:17 +02:00
nala d186f94206 feat: demonstrar percurso IVA QA e consolidar revisao 2026-10-21 09:40:49 +02:00
nala 83d2f97d82 feat: preparar lote IVA exacto em QA sem activacao 2026-10-21 09:22:11 +02:00
nala 37085b40c1 docs: fundamentar base IVA e delimitar lacuna de arredondamento 2026-10-21 09:03:01 +02:00
nala 325d58b694 docs: preparar ficha fiscal IVA v1 sem activacao 2026-10-21 08:57:56 +02:00
nala 0fafd348e7 docs: delimitar entrega de planeamento e propor primeiro lote fiscal 2026-10-21 08:47:07 +02:00
nala 13f68f1bd8 fix: concluir recuperacao de exportacoes e consolidar candidata QA
tests / ci (push) Canceled after 0s
2026-10-21 08:43:12 +02:00
nala c22003a3da fix: recover simulator cashflow and verify QA continuity and report assumptions 2026-10-20 22:00:48 +02:00
nala a690c665fb fix: validate simulator dates and reconcile QA calculations reports and language 2026-10-20 21:00:52 +02:00
nala 59ab4fe956 fix: validar datas fiscais e iniciar revisao integrada do simulador 2026-10-20 20:03:40 +02:00
nala e69ab1499f docs: comprovar guias na biblioteca QA existente
tests / ci (push) Canceled after 0s
2026-10-20 19:49:55 +02:00
nala 3258a25f76 feat: apresentar guias educativos com imagem resumo e contexto 2026-10-20 19:45:45 +02:00
nala 0e7d51f18f fix: concluir apresentacao publica dos negocios e zonas
tests / ci (push) Canceled after 0s
2026-10-20 19:00:32 +02:00
nala fd341b627e fix: harmonizar pesquisa e navegacao dos catalogos em QA 2026-10-20 18:53:41 +02:00
nala 1350b4df73 autenticacao 1
tests / ci (push) Canceled after 0s
2026-10-20 17:51:31 +02:00
nala 8cf820ef49 Reservar espaco minimo antes de compilar nova release
tests / ci (push) Canceled after 0s
2026-10-20 17:26:01 +02:00
nala 210b7d273b Clarificar comando de consulta para demonstrar entrega automatica 2026-10-20 17:25:29 +02:00
nala be49f4c39c Integrar navbar e sobre dos programadores preservando identidade e acessos 2026-10-20 17:23:45 +02:00
nala 9b3c20fc2a Registar resolucao do diagnostico e alinhar traducao financeira aceite 2026-10-20 17:23:20 +02:00
nala de061b81aa Conferir alteracoes durante CI e sinalizar falha ao systemd 2026-10-20 15:12:54 +02:00
nala 6e439def02 Reconciliar verificacoes com navegacao e relatorios aprovados 2026-10-20 15:12:17 +02:00
nala 74b081e93e Evitar migracao desnecessaria no teste unitario de exemplo 2026-10-20 15:09:51 +02:00
nala 9d148ca241 Preservar diagnostico mais recente dos programadores e isolar fixtures unitarias 2026-10-20 15:07:54 +02:00
nala 5be831ac44 Usar contrato QA existente e documentar operacao e reversao 2026-10-20 15:06:45 +02:00
nala f0e26e0d8a Preparar cadeia Git com verificacao isolada e releases reversiveis 2026-10-20 14:57:08 +02:00
nala 9ab721c97f Reconciliar fontes instaladas 423bd512d8c5 com main 2026-10-20 14:45:19 +02:00
paulomassigue 02d4e6b2fd remocao de publicidade em sobre nos e alinhamento de items no navbar
tests / ci (push) Canceled after 0s
2026-10-20 14:38:37 +02:00
paulomassigue 507387502c Correcao no aviso dos chunks
tests / ci (push) Waiting to run
2026-10-20 12:08:09 +02:00
paulomassigue 97a08df2a0 ajuste no header e organizaco de elementos, e ajuste no footer
tests / ci (push) Canceled after 0s
2026-10-20 11:39:25 +02:00
nala a128c5a1b8 Merge origin/main preservando reconciliacao da matriz
tests / ci (push) Canceled after 0s
2026-10-20 09:14:24 +02:00
nala 6e57d1f601 autenticacao 2026-10-20 09:09:57 +02:00
paulomassigue 9bc450e87c correcao do tamanho da frase:invista em mocambique
tests / ci (push) Canceled after 0s
2026-10-19 15:13:54 +02:00
paulomassigue cb438b2b28 correcao dos links do footer
tests / ci (push) Canceled after 0s
2026-10-19 15:01:58 +02:00
paulomassigue becb211f8c ajuste de texto inicial e centralizacao da informaco
tests / ci (push) Canceled after 0s
2026-10-19 14:45:30 +02:00
paulomassigue 14ad2f60c5 header:navbar e footer
tests / ci (push) Canceled after 0s
2026-10-19 13:07:16 +02:00
nala 3ca67a53c1 chore: exclude local QA outputs and nested dependencies
tests / ci (push) Canceled after 0s
2026-10-18 16:00:55 +02:00
nala cf003c8623 versao nrgocios 4
tests / ci (push) Canceled after 0s
2026-10-18 15:57:06 +02:00
nala 6bc0cc6f52 versao nrgocios 3
tests / ci (push) Canceled after 0s
2026-10-18 11:17:35 +02:00
nala cef6e201c7 versao nrgocios 2
tests / ci (push) Canceled after 0s
2026-10-16 20:11:05 +02:00
nala 2d23157e40 versao nrgocios
tests / ci (push) Canceled after 0s
2026-10-16 13:06:58 +02:00
nala 6a76aac80f versao compilada 1
tests / ci (push) Canceled after 0s
2026-10-16 09:17:28 +02:00
nala c13f250b87 versao compilada
tests / ci (push) Canceled after 0s
2026-10-15 17:37:00 +02:00
nala 9a96ee8dfe oportunidades 10
tests / ci (push) Canceled after 0s
2026-10-15 08:50:21 +02:00
nala 5738418838 oportunidades 9
tests / ci (push) Canceled after 0s
2026-10-14 16:57:56 +02:00
nala ba1c0340ea oportunidades 8
tests / ci (push) Canceled after 0s
2026-10-14 08:25:30 +02:00
nala 287e991486 oportunidades 7
tests / ci (push) Waiting to run
2026-10-13 11:40:29 +02:00
nala 20971d523f oportunidades 6
tests / ci (push) Canceled after 0s
2026-10-10 21:17:46 +02:00
nala 01405cbf8a oportunidades 5
tests / ci (push) Canceled after 0s
2026-10-10 14:15:28 +02:00
nala 6ddafc95ba oportunidades 4
tests / ci (push) Canceled after 0s
2026-10-10 07:03:59 +02:00
nala e662230df7 oportunidades 3
tests / ci (push) Canceled after 0s
2026-10-09 15:00:37 +02:00
nala 9d6176f55b feat: verify active opportunity management and prepare versioned resources 2026-10-09 13:16:41 +02:00
nala 60784a2ed2 oportunidades 2
tests / ci (push) Canceled after 0s
2026-10-09 08:48:42 +02:00
nala 7ecfe3d543 feat: integrate opportunity draft management with audited lifecycle 2026-10-09 08:16:51 +02:00
nala ebfc188240 Confirm active opportunity classifications with read-only audit 2026-10-09 07:38:01 +02:00
nala ae789ce024 vrsao 2
tests / ci (push) Canceled after 0s
2026-10-09 02:57:39 +02:00
nala 344545430b Add audited opportunity origin review and QA separation 2026-10-09 02:46:36 +02:00
nala 6c7a9c9442 Exige motivo na reversão territorial e prepara novo plano 2026-10-09 02:15:36 +02:00
nala 6a71aa47e3 Consolida rastreabilidade territorial e plano autenticado V2 2026-10-09 02:05:44 +02:00
nala 30acdc9934 Liga acervo documental e guias a jornada do investidor 2026-10-08 18:54:24 +02:00
nala 51c7fb92b1 Corrige validacao, recuperacao e acesso ao calculo do rascunho 2026-10-08 18:07:29 +02:00
nala 1106509116 vrsao 2
tests / ci (push) Canceled after 0s
2026-10-08 14:02:01 +02:00
nala bdd766c68a resolvemos o problema dos ZEE e ZFI
tests / ci (push) Canceled after 0s
2026-10-07 19:46:35 +02:00
nala 0a086c3fa5 resolvemos o problema dos ZEE e ZFI
tests / ci (push) Canceled after 0s
2026-10-07 14:00:55 +02:00
nala 59d108a576 resolvemos o problema dos ZEE e ZFI
tests / ci (push) Canceled after 0s
2026-10-07 09:08:43 +02:00
nala e65cb26901 varias alteracoes
tests / ci (push) Canceled after 0s
2026-10-06 14:50:12 +02:00
nala 4f35313d06 novas funcionalidades dos formulario
tests / ci (push) Canceled after 0s
2026-10-02 11:13:37 +02:00
nala a6afb4e97a correcao do seeder
tests / ci (push) Canceled after 0s
2026-09-30 13:57:28 +02:00
nala cbae1ce77b correcao do erro no ficheiro catalog.tsx
tests / ci (push) Canceled after 0s
2026-09-30 13:50:28 +02:00
nala 5e6b32b493 dados do seeder
tests / ci (push) Waiting to run
2026-09-30 13:47:51 +02:00
nala 3395424d82 resolucao dos inputs das provincias, distritos, localidades
tests / ci (push) Waiting to run
2026-09-30 12:59:28 +02:00
nala 3dc7c5c649 commit 30/9
tests / ci (push) Canceled after 0s
2026-09-30 09:05:43 +02:00
nala 67dad461d9 provincias
tests / ci (push) Canceled after 0s
2026-09-29 14:14:06 +02:00
1807 changed files with 348432 additions and 14444 deletions
+16
View File
@@ -0,0 +1,16 @@
.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
+40 -6
View File
@@ -4,6 +4,11 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
# Separate from publication. Bind only an explicitly authorised published rule version.
INVESTMOZ_IVA_CONSUMER_ENABLED=false
INVESTMOZ_IVA_CONSUMER_RULE_ID=
INVESTMOZ_IVA_CONSUMER_RULE_VERSION=0
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@@ -20,12 +25,14 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
# Full application schema requires PostgreSQL with PostGIS; SQLite is unsupported.
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=investemoz
# Configure a least-privilege application role; provision PostGIS before migrations.
DB_USERNAME=investmoz_app
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
@@ -36,6 +43,16 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
DB_QUEUE_RETRY_AFTER=210
MALWARE_SCANNER=clamd
CLAMD_HOST=127.0.0.1
CLAMD_PORT=3310
CLAMD_TIMEOUT_SECONDS=120
CLAMD_MAX_SIGNATURE_AGE_HOURS=48
CLAMD_MAX_FILE_BYTES=20971520
CLAMD_MAX_STREAM_BYTES=26214400
CLAMD_MAX_DECOMPRESSED_BYTES=104857600
CLAMD_MAX_ARCHIVE_ENTRIES=1000
CACHE_STORE=database
# CACHE_PREFIX=
@@ -66,6 +83,15 @@ VITE_APP_NAME="${APP_NAME}"
# Comma-separated emails of verified accounts authorised to manage the portal.
PORTAL_MANAGER_EMAILS=
# Explicit, separate application workflow designations. Empty means no access.
INVESTMOZ_MNE_TRIAGERS=
INVESTMOZ_MNE_REVIEWERS=
INVESTMOZ_MNE_APPROVERS_L1=
INVESTMOZ_MNE_APPROVERS_L2=
INVESTMOZ_MNE_PUBLISHERS=
INVESTMOZ_CONSENT_POLICY_VERSION=2026-09-30
INVESTMOZ_SHARING_POLICY_VERSION=2026-09-30
INVESTMOZ_SLA_ESCALATION_ENABLED=false
PORTAL_NOTIFICATIONS_ENABLED=false
PORTAL_NOTIFICATION_RECIPIENTS=
PORTAL_SUPPORT_EMAIL=
@@ -85,3 +111,11 @@ SUITECRM_BASE_URL=
SUITECRM_CLIENT_ID=
SUITECRM_CLIENT_SECRET=
SUITECRM_CACHE_STORE=database
# Tax-rule governance roles are empty by default and require named verified managers with MFA.
INVESTMOZ_TAX_RULE_EDITORS=
INVESTMOZ_TAX_RULE_REVIEWERS=
INVESTMOZ_TAX_RULE_APPROVERS=
INVESTMOZ_TAX_RULE_PUBLISHERS=
INVESTMOZ_ZONE_TERRITORY_REVIEWERS=
+3
View File
@@ -1,5 +1,8 @@
* text=auto eol=lf
# Preserve the exact bytes pinned by EconomicReferenceCatalog::MANIFEST_SHA256.
database/data/economic-references-lot-01.json text eol=crlf
*.blade.php diff=html
*.css diff=css
*.html diff=html
BIN
View File
Binary file not shown.
+14 -4
View File
@@ -20,7 +20,7 @@ jobs:
POSTGRES_USER: investmoz_test
POSTGRES_PASSWORD: test-only-password
ports:
- 5432:5432
- 55441:5432
options: >-
--health-cmd "pg_isready -U investmoz_test -d investmoz_test"
--health-interval 10s --health-timeout 5s --health-retries 5
@@ -28,7 +28,7 @@ jobs:
APP_ENV: testing
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_PORT: 55441
DB_DATABASE: investmoz_test
DB_USERNAME: investmoz_test
DB_PASSWORD: test-only-password
@@ -63,8 +63,18 @@ jobs:
npm ci
npm run build
- name: Prevent accidental diagnostic exposure (C132)
run: |
php tools/portal-diagnostics-deployment-guard.php --self-test
php tools/portal-diagnostics-deployment-guard.php
- name: Run CI Checks
run: composer ci:check
- name: Test frontend API contracts
run: node --test tests/frontend/portal-api.test.mjs
- name: Validate PostgreSQL/PostGIS schema lifecycle
env:
INVESTMOZ_SCHEMA_TESTS: '1'
run: php vendor/bin/phpunit --configuration=phpunit.investmoz.xml --do-not-cache-result
- name: Test all frontend contracts and calculators
run: node --test tests/frontend/*.test.mjs
+4
View File
@@ -1,7 +1,11 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
**/node_modules/
**/vendor/
/outputs/
/public/build
/public/build-qa-opportunities
/public/fonts-manifest.dev.json
/public/hot
/public/storage
+66
View File
@@ -0,0 +1,66 @@
# Instruções permanentes do projeto InvesteMoz
## 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.
2. As regras de funcionamento são ditadas pela Tempo, Lda.
3. Os papéis dos seus funcionários são determinados e regidos pela Tempo, Lda.
4. As funcionalidades do portal são determinadas pela Tempo, Lda.
5. Este portal é de âmbito nacional e serve tanto a pessoas nacionais como estrangeiras e exigirá vários intervenientes para a sua operação (manutenção, cadastro, viabilização dos negócios, etc.). Se as regras não são ditadas por quem opera e, ao mesmo tempo, é proprietário, a sua viabilidade é nula.
6. Os constrangimentos criados sem a autorização da Tempo e que impedem o desenvolvimento normal devem ser removidos ou alterados.
7. Antes de iniciar a execução de uma nova funcionalidade, verificar quais são os constrangimentos que podem criar atrasos e círculos viciosos de correcção de erros que poderiam ter sido evitados.
8. As instruções anteriores ditadas pela Tempo mantêm-se.
Registar a origem das restrições: decisão expressa da Tempo, requisito anterior aprovado ou escolha de implementação. Uma escolha de implementação não constitui uma nova exigência organizacional. As autorizações e limites específicos de cada tarefa mantêm-se, incluindo a separação entre preparação em QA e alteração no ativo.
### Aplicação na execução — referência permanente confirmada em 12/10/2026
A Tempo, Lda define o produto, as regras operacionais e as funções dos seus funcionários. As instruções anteriores mantêm-se. Esta formulação consolida as oito regras acima e orienta a leitura das especificações e da matriz de auditoria existente.
- Cada restrição deve ter origem identificável: decisão expressa da Tempo, referência e versão de uma instrução anterior aprovada ou escolha técnica. Escolhas técnicas não se tornam regras de negócio sem autorização da Tempo.
- Constrangimentos introduzidos sem autorização devem ser identificados e removidos ou ajustados, preservando dados, relações, histórico e instruções aprovadas.
- Antes de implementar, verificar as dependências técnicas e operacionais que possam causar bloqueios previsíveis. Distinguir dependências reais de opções de implementação e apresentar antecipadamente as decisões concretas necessárias.
- O portal deve contemplar investidores nacionais e estrangeiros e os diferentes intervenientes da operação, incluindo manutenção, cadastro e viabilização de negócios. O âmbito do portal é nacional.
Referenciar estas orientações nos requisitos correspondentes da matriz existente, sem criar outra matriz ou backlog. A referência documental não encerra um requisito funcional nem substitui a demonstração do seu critério de aceitação.
## Aceitação das fontes oficiais — decisão do responsável, 08/10/2026
- Dados e documentos com origem oficial/governamental identificada (incluindo BR, decretos e leis) são aceites pelo responsável como fontes fidedignas e válidas. Não exigir segunda ou terceira validação da autenticidade/proveniência oficial, nem certificação por outra fonte.
- Registar e recuperar origem, exemplar, versão e citação é rastreabilidade; a ausência desses campos num cadastro não revoga a aceitação nem torna o dado inválido. Preservar as validações e decisões já documentadas.
- Esta decisão tem precedência sobre pendências genéricas de revalidação de proveniência oficial em relatórios históricos. Não estender a classificação oficial a documentos de promotores ou de programação sem origem oficial identificada.
- A aceitação da fonte não substitui a identificação do âmbito, período aplicável, alterações/revogações expressas no próprio acervo oficial ou dos parâmetros necessários a uma regra/cálculo. Conferir esses elementos no documento oficial é aplicação da fonte aceite, não uma segunda validação da sua credibilidade. Não ativar cálculos por importação/publicação.
- O responsável define o acervo e a finalidade. Inventariar todos os ficheiros e anexos acessíveis antes de seleccionar documentos para consulta; não omitir por não serem usados por uma funcionalidade.
- Manter três estados independentes: disponibilidade para consulta, situação jurídica/documental e utilização numa regra ou cálculo. Importação/publicação nunca activa um cálculo fiscal.
- Não usar associação a módulo, requisito ou artigo como pré-condição para cadastrar ou consultar um documento autorizado. Relações sem citação verificada ficam pendentes e visivelmente identificadas.
- Respeitar a autorização de divulgação registada e o seu âmbito. Só bloquear por impedimento concreto (por exemplo malware, dados pessoais/confidenciais ou restrição efectiva de divulgação), registado por ficheiro. Não atribuir decisões a gestores inexistentes nem contornar autenticação.
- Importações em lote devem ter pré-visualização, cálculo de hash, detecção de duplicados por bytes e conflitos de identidade/versão, escrita privada, análise antimalware e execução idempotente. Nunca publicar automaticamente um lote.
- 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.
+13 -1
View File
@@ -1,4 +1,16 @@
# Portal do Investidor — esqueleto Laravel
# Portal do Investidor — InvesteMoz
## Executar esta plataforma no Windows
Consulte [Execução local](deploy/local/README.md). Com Docker Desktop iniciado,
execute `./deploy/local/start.ps1`. Depois da preparação, `composer run dev`
inicia a plataforma em http://127.0.0.1:8000.
O ambiente local usa uma base PostgreSQL/PostGIS de demonstração isolada.
Não execute os exemplos históricos de criação de um projecto novo sobre esta
instalação ou sobre dados existentes.
## Referência histórica do pacote inicial
Estes ficheiros correspondem ao domínio de **sectores**, **oportunidades** e
**manifestações de interesse** do Portal do Investidor. Foram desenhados
+1112
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Services\Mne\CompatibilityBackfill;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class BackfillMneCompatibility extends Command
{
protected $signature = 'mne:backfill-compatibility {--batch=100} {--dry-run : Execute validation and roll back all changes}';
protected $description = 'Reconcile compatibility identifiers without copying canonical business records';
public function handle(CompatibilityBackfill $backfill): int
{
$batch = filter_var($this->option('batch'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 1000]]);
if ($batch === false) {
$this->error('Batch must be between 1 and 1000');
return self::FAILURE;
}
if ($this->option('dry-run')) {
DB::beginTransaction();
try {
$counts = $backfill->run($batch);
} finally {
DB::rollBack();
}
$this->line(json_encode(['dry_run' => true, 'rolled_back' => true, 'counts' => $counts], JSON_THROW_ON_ERROR));
} else {
$this->line(json_encode($backfill->run($batch), JSON_THROW_ON_ERROR));
}
return self::SUCCESS;
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Sleep;
use Throwable;
class CheckDevelopmentEnvironment extends Command
{
protected $signature = 'portal:dev-check {--wait=0 : Segundos para aguardar uma falha transitória do PostgreSQL}';
protected $description = 'Verifica o arranque local sem alterar dados ou mostrar credenciais';
public function handle(): int
{
$wait = filter_var($this->option('wait'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 3600]]);
if ($wait === false) {
$this->error('--wait deve ser um inteiro entre 0 e 3600 segundos.');
return self::INVALID;
}
$checks = [
'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='),
'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'),
'APP_KEY definida' => filled(config('app.key')),
'Base PostgreSQL configurada' => config('database.default') === 'pgsql',
'Cache de vistas gravável' => is_dir(config('view.compiled') ?: '') && is_writable(config('view.compiled') ?: ''),
'Sessões e logs graváveis' => is_writable(storage_path('framework/sessions')) && is_writable(storage_path('logs')),
'Dependências frontend instaladas' => is_file(base_path('node_modules/.bin/vp')) || is_file(base_path('node_modules/.bin/vp.cmd')),
];
if ($checks['Driver PostgreSQL activo'] && $checks['Base PostgreSQL configurada']) {
$checks['Ligação à base de dados'] = $this->checkConnection($wait);
}
if ($checks['Ligação à base de dados'] ?? false) {
try {
$checks['Extensão PostGIS instalada'] = (bool) DB::selectOne("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")->installed;
$checks['Tabelas principais instaladas'] = collect(['migrations', 'users', 'sectors', 'opportunities', 'sessions', 'cache', 'jobs'])
->every(fn (string $table): bool => Schema::hasTable($table));
} catch (Throwable) {
$checks['Esquema da base de dados verificável'] = false;
$this->warn('A ligação foi estabelecida, mas a verificação do esquema falhou. Verifique as permissões e a preparação QA.');
}
}
foreach ($checks as $label => $passed) {
$this->line(($passed ? '[OK] ' : '[FALHA] ').$label);
}
if (in_array(false, $checks, true)) {
$this->error('Arranque interrompido. Consulte deploy/local/README.md e corrija os itens acima.');
return self::FAILURE;
}
return self::SUCCESS;
}
private function checkConnection(int $wait): bool
{
$deadline = microtime(true) + $wait;
while (true) {
try {
DB::selectOne('SELECT 1');
return true;
} catch (Throwable $error) {
[$reason, $retryable] = $this->connectionFailure($error);
$remaining = $deadline - microtime(true);
if (! $retryable || $remaining <= 0) {
$this->warn($reason);
if ($retryable) {
$this->warn('Inicie Docker Desktop e execute ./deploy/local/start.ps1. Consulte os registos com docker compose --env-file .env -f deploy/local/compose.yaml logs --tail 40 postgres.');
}
return false;
}
$this->line('[AGUARDA] '.$reason.' Nova tentativa; prazo restante: '.(int) ceil($remaining).' s.');
DB::purge();
Sleep::for(min(5, $remaining))->seconds();
}
}
}
/** @return array{string, bool} */
private function connectionFailure(Throwable $error): array
{
// Inspect the chain internally; never print exception messages, DSNs or credentials.
$messages = '';
do {
$messages .= ' '.strtolower($error->getMessage()).' '.strtolower((string) $error->getCode());
$error = $error->getPrevious();
} while ($error !== null);
if (str_contains($messages, '28p01') || str_contains($messages, '28000')
|| str_contains($messages, 'password authentication failed') || str_contains($messages, 'no pg_hba.conf entry')) {
return ['Autenticação PostgreSQL recusada. Verifique as credenciais e as regras de acesso; aguardar não corrige este erro.', false];
}
if (str_contains($messages, '3d000') || preg_match('/database .* does not exist/', $messages)) {
return ['A base PostgreSQL configurada não existe. Verifique a configuração e a preparação QA.', false];
}
if (str_contains($messages, '57p03') || str_contains($messages, 'recovery mode')
|| str_contains($messages, 'not yet accepting connections') || str_contains($messages, 'database system is starting up')) {
return ['PostgreSQL em arranque ou recuperação; aguarde a conclusão sem remover o volume.', true];
}
if (preg_match('/\b08[0-9a-z]{3}\b/', $messages) || str_contains($messages, 'connection refused')
|| str_contains($messages, '57p01') || str_contains($messages, '57p02')) {
return ['PostgreSQL indisponível. Verifique se Docker Desktop e o contentor local estão iniciados.', true];
}
return ['Falha PostgreSQL não transitória. Verifique a configuração e os registos do servidor.', false];
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Throwable;
class CheckDocumentLibrarySchema extends Command
{
protected $signature = 'portal:check-document-library-schema';
protected $description = 'Confere o esquema e as migrações da biblioteca documental antes da activação';
public function handle(): int
{
$reference = (string) Str::uuid();
try {
$required = [
'portal_resources' => [
'id', 'document_id', 'title', 'category', 'topic', 'legislation', 'essential', 'published',
'source_reference', 'rights_status', 'description', 'document_type', 'document_number',
'document_date', 'source_url', 'language', 'version_label', 'document_group_id',
'applicable_from', 'applicable_until',
'territorial_scope', 'territory_id', 'source_confirmation', 'legal_status', 'last_reviewed_at',
'document_identity_key', 'duplicate_of_resource_id', 'metadata_version', 'created_by',
'last_reviewed_by', 'independent_review',
],
'documents' => [
'id', 'status', 'data_classification', 'sha256', 'deleted_at', 'file_name', 'media_type',
'storage_key', 'size_bytes', 'version_no', 'issuer', 'issued_on', 'valid_until', 'uploaded_by',
'rights_status', 'original_source', 'malware_scan_status', 'malware_scanned_sha256',
'malware_engine', 'malware_engine_version', 'malware_signatures_at', 'malware_scanned_at',
'malware_failure_reason', 'malware_scan_attempts',
],
'portal_resource_links' => ['id', 'resource_id', 'target_type', 'target_key', 'relationship', 'applicability'],
'portal_resource_revisions' => ['id', 'resource_id', 'revision_no', 'change_type', 'snapshot'],
'document_malware_scans' => ['id', 'document_id', 'file_sha256', 'result', 'engine', 'engine_version', 'started_at'],
];
$missing = [];
foreach ($required as $table => $columns) {
if (! Schema::hasTable($table)) {
$missing[] = "tabela {$table}";
continue;
}
foreach (array_diff($columns, Schema::getColumnListing($table)) as $column) {
$missing[] = "{$table}.{$column}";
}
}
$requiredMigrations = [
'2026_10_05_120000_extend_portal_document_catalogue',
'2026_10_05_130000_add_document_malware_scanning',
];
if (! Schema::hasTable('migrations')) {
$missing[] = 'repositório de migrações';
} else {
$ran = DB::table('migrations')->whereIn('migration', $requiredMigrations)->pluck('migration')->all();
foreach (array_diff($requiredMigrations, $ran) as $migration) {
$missing[] = "migração {$migration}";
}
}
if ($missing !== []) {
$this->error('Esquema da biblioteca incompatível. Não activar o catálogo; faltam elementos de esquema ou migrações.');
foreach ($missing as $item) {
$this->line('- '.$item);
}
return self::FAILURE;
}
foreach ([
['portal_resources', 'rights_status', 'unconfirmed'],
['documents', 'rights_status', 'unconfirmed'],
['documents', 'malware_scan_status', 'pending'],
] as [$table, $column, $expectedDefault]) {
$default = DB::selectOne(
'select column_default from information_schema.columns where table_schema = current_schema() and table_name = ? and column_name = ?',
[$table, $column],
)?->column_default;
if (! is_string($default) || ! str_contains(strtolower($default), $expectedDefault)) {
$this->error("Valor predefinido inseguro ou ausente em {$table}.{$column}; preservar os registos e rever a migração.");
return self::FAILURE;
}
}
$invalidCleanRecords = DB::table('documents')->where('malware_scan_status', 'clean')
->where(function ($query): void {
$query->whereNull('malware_scanned_sha256')->orWhereColumn('malware_scanned_sha256', '!=', 'sha256');
})->count();
if ($invalidCleanRecords > 0) {
$this->error('Existem documentos marcados como limpos sem correspondência do hash analisado.');
return self::FAILURE;
}
$this->info('Esquema da biblioteca compatível; verificações de direitos e análise antimalware disponíveis.');
return self::SUCCESS;
} catch (Throwable $exception) {
Log::error('Document library schema compatibility check failed.', ['reference' => $reference, 'exception' => $exception]);
$this->error('Não foi possível verificar a compatibilidade do esquema. Consulte o registo interno. Referência: '.$reference);
return self::FAILURE;
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Services\Malware\MalwareScanner;
use Illuminate\Console\Command;
class CheckMalwareScanner extends Command
{
protected $signature = 'malware:health';
protected $description = 'Check ClamAV availability and signature freshness';
public function handle(MalwareScanner $scanner): int
{
$health = $scanner->health();
$this->line('Scanner: '.($health['engine']));
$this->line('Available: '.($health['available'] ? 'yes' : 'no'));
$this->line('Engine version: '.($health['engine_version'] ?? 'unavailable'));
$this->line('Signatures UTC: '.($health['signatures_at']?->format(DATE_ATOM) ?? 'unavailable'));
$this->line('Signature age hours: '.($health['signature_age_hours'] === null ? 'unavailable' : number_format($health['signature_age_hours'], 2)));
if (! $health['available']) {
$this->error('Reason: '.($health['reason_code'] ?? 'scanner_unavailable'));
return self::FAILURE;
}
return self::SUCCESS;
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class EscalateOverdueInvestmentTasks extends Command
{
protected $signature = 'investmoz:escalate-overdue-tasks';
protected $description = 'Eleva a prioridade de tarefas operacionais vencidas, quando activado.';
public function handle(): int
{
if (! config('existing-business.sla_escalation_enabled')) {
$this->components->info('Escalada SLA desactivada.');
return self::SUCCESS;
}
$count = DB::table('tasks')->whereIn('task_type', ['triage', 'interest_follow_up'])
->whereIn('status', ['open', 'in_progress'])->whereNotNull('due_at')->where('due_at', '<', now())
->where('priority', '>', 1)->update(['priority' => 1, 'outcome' => 'sla_overdue', 'updated_at' => now()]);
$this->components->info("Tarefas elevadas para prioridade máxima: {$count}.");
return self::SUCCESS;
}
}
@@ -0,0 +1,427 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
class ImportDocumentBatch extends Command
{
protected $signature = 'documents:import-batch {manifest : JSON manifest inside storage/app/private/document-import-staging} {--apply : Register private drafts and queue malware scans} {--actor= : Existing user ID responsible for an applied import} {--pilot-agronegocio : Restrict to the approved two-original pilot} {--sectors-lot20 : Restrict to the named 20-resource sector batch} {--sectors-trio : Restrict to the three named sector articles and reuse the existing image} {--backup-proof= : Fresh verified destination backup receipt for active pilot apply}';
protected $description = 'Preview or idempotently import a document batch into the existing private library';
private const MAX_BYTES = 10485760;
private const PILOT_MANIFEST_SHA256 = '0b720b0a6f7539de1d3eca9e32f89a6b9038848dc0b97ad21ace605154d9ddd8';
private const PILOT_HASHES = [
'legacy-tempo-post-171985-20260420' => '0929184a6d4645b9046512675394ad4f60575273f6dab0955f71cc7474a6a4e2',
'legacy-tempo-agronegocio-image-5e3840705cf98226' => '5e3840705cf98226d1f189cf665aaa98cb05da96c645159a1332a1d429d642eb',
];
private const SECTORS_MANIFEST_SHA256 = 'ade9a00c031534f4341e8f6bd5e425b502ee03efc9fcfaec4f463a10fb28201b';
private const SECTORS_HASHES = [
'legacy-tempo-post-171981-20260430' => '26a70f16cd511c579f08633655ff312db05122f81e44852dd4b163fa6eef85f1',
'legacy-tempo-post-172679-20260430' => '2003755b7dac53b43f4608e707321db6d2f29878926a0e74f4cf27627b70a9cb',
'legacy-tempo-post-172674-20260430' => '3ae780f082a8fa7c767635cce98563f5bc027c9656c9f41f8788843a17fa1f54',
'legacy-tempo-post-171990-20260616' => '8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72',
'legacy-tempo-post-172263-20260414' => '538495cd5b31047ac1c5993fdaf298ec1303330368248b251c87d201d31ff211',
'legacy-tempo-post-172256-20260414' => 'dcc29cbd36cf57e0a5c88dd312c84ee03c32a3d18ae9216d3715dc7b5a6960bf',
'legacy-tempo-post-172261-20260414' => '88177fccd78d8516dba670c6f71272af062cc981d52d97adefe5bf77c613b16a',
'legacy-tempo-post-172258-20260417' => '48314a576dea2f2dadf47d8f1eadd7ab58030606b02eebe17aa9bd6badffb7e9',
'legacy-tempo-post-172253-20260414' => 'e90e39e49ba65bbbcbbc303c6eca456e445238daf9acc935ab03690819281678',
'legacy-tempo-post-172240-20260414' => '8c5e91c937032680883793a12844f1128f7ac99e97c63b68c23d1396e009ba9d',
'legacy-tempo-post-172237-20260417' => 'a62a43e31da32795a7286f4c152a93d5dc829233e72023b00ea61fd8e2fcb58c',
'legacy-tempo-post-172233-20260417' => '345dfd72d8ebf459eae8eb025d699afddbae751df55ffa8d053568cd08096c97',
'legacy-tempo-post-172230-20260414' => '4f4570f7bbd5599cb925ed996c1734a23582362fe485736931a53b9266565d4e',
'legacy-tempo-post-172223-20260414' => '514a9d88c097d234038fa6341f5a5b01e8938cb6a9535a807145c84c37010a4d',
'legacy-tempo-post-172219-20260414' => '0d468ae0de9a76d2b140bc8cb5c555f29626fb6422287c917762bec3e615a70c',
'legacy-tempo-post-171992-20260430' => '05eb492dd6cfca6a7f305b633658a16723c315168f4b2c75103f22d1fac84032',
'legacy-tempo-image-0a2c50d91dad3a60' => '0a2c50d91dad3a6007544634540e0f61b11d3506682a5a19066bae03cdcba490',
'legacy-tempo-image-28f41f12e10ab345' => '28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa',
'legacy-tempo-image-fe9a2e382e385b35' => 'fe9a2e382e385b35f6e61ae320e199a979678677fd327fb7d4b07d8ce0111c11',
'legacy-tempo-image-8e58544e49e719f9' => '8e58544e49e719f9c865c91e07ef1030155a9e3d59260e6dc93e9c9e25674e39',
];
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',
'legacy-tempo-post-173213-20260616' => '0ba605ae43dec7f685610f828df884bc82cb4da67131fc9783b5cdbe4cc6d9f0',
];
public function handle(): int
{
$staging = storage_path('app/private/document-import-staging');
$manifestPath = realpath($staging.DIRECTORY_SEPARATOR.$this->argument('manifest'));
$stagingReal = realpath($staging);
if (! $manifestPath || ! $stagingReal || ! str_starts_with(strtolower($manifestPath), strtolower($stagingReal.DIRECTORY_SEPARATOR)) || ! is_file($manifestPath)) {
$this->error('Manifest is missing or outside the private document-import staging directory.');
return self::FAILURE;
}
foreach ([
'documents' => ['id', 'source_system', 'external_id', 'version_no', 'data_classification', 'file_name', 'media_type', 'storage_key', 'sha256', 'status', 'size_bytes', 'issuer', 'issued_on', 'uploaded_by', 'rights_status', 'original_source', 'malware_scan_status'],
'portal_resources' => ['document_id', 'title', 'category', 'topic', 'published', 'source_reference', 'approved_by', 'description', 'document_type', 'document_number', 'document_date', 'source_url', 'language', 'version_label', 'source_confirmation', 'legal_status', 'rights_status', 'document_identity_key', 'metadata_version', 'created_by'],
] as $table => $columns) {
if (! Schema::hasColumns($table, $columns)) {
$this->error('Document-library schema is incomplete; run the documented schema compatibility check before importing.');
return self::FAILURE;
}
}
try {
$manifestBytes = (string) file_get_contents($manifestPath);
$manifest = json_decode($manifestBytes, true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable) {
$this->error('Manifest is not valid JSON.');
return self::FAILURE;
}
if (! is_array($manifest) || ! isset($manifest['documents']) || ! is_array($manifest['documents']) || count($manifest['documents']) > 100) {
$this->error('Manifest must contain at most 100 documents in a documents array.');
return self::FAILURE;
}
$apply = (bool) $this->option('apply');
$actorId = $this->option('actor');
$pilot = (bool) $this->option('pilot-agronegocio');
$sectors = (bool) $this->option('sectors-lot20');
$trio = (bool) $this->option('sectors-trio');
if ((int) $pilot + (int) $sectors + (int) $trio > 1) {
$this->error('Select exactly one named batch; agribusiness scope is unchanged.');
return self::FAILURE;
}
if ($pilot || $sectors || $trio) {
try {
$this->validatePilot($manifestPath, $manifestBytes, $manifest, $stagingReal, $apply, $actorId, $sectors, $trio);
} catch (Throwable $exception) {
$this->error('Pilot refused before import: '.$exception->getMessage());
return self::FAILURE;
}
}
if ($apply && ! $pilot && ! $sectors && ! $trio) {
// CLI attribution is not an authenticated active installation. Rehearsals stay isolated.
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
if (! app()->environment('testing') || ! str_ends_with($target->database, '_test') || (int) $target->port !== 55441
|| config('database.connections.pgsql.host') !== '127.0.0.1' || config('database.connections.pgsql.url')) {
$this->error('CLI apply is restricted to isolated QA. Active imports require the authenticated activation mechanism; --actor is attribution only.');
return self::FAILURE;
}
}
if ($apply && (! is_numeric($actorId) || ! DB::table('users')->where('id', (int) $actorId)->exists())) {
$this->error('Apply requires --actor=<existing-user-id>; no author identity will be fabricated.');
return self::FAILURE;
}
if ($apply && ! User::findOrFail((int) $actorId)->can('manage-portal')) {
$this->error('QA import requires an authorized library manager; no permission is granted by naming an account.');
return self::FAILURE;
}
$disk = Storage::disk('local');
$seenHashes = [];
$seenExternalIds = [];
$dispatch = [];
$failures = 0;
foreach ($manifest['documents'] as $index => $record) {
$label = is_array($record) ? (string) ($record['external_id'] ?? '#'.($index + 1)) : '#'.($index + 1);
try {
$validated = $this->validateRecord($record, $stagingReal);
if (($pilot || $sectors || $trio) && ! hash_equals(($trio ? self::TRIO_HASHES : ($sectors ? self::SECTORS_HASHES : self::PILOT_HASHES))[$validated['external_id']] ?? '', $validated['sha256'])) {
throw new RuntimeException('Approved pilot bytes changed after preview validation.');
}
$hash = $validated['sha256'];
if (isset($seenHashes[$hash])) {
$this->line($label.' | duplicate bytes within batch; references item '.$seenHashes[$hash]);
continue;
}
$seenHashes[$hash] = $label;
if (isset($seenExternalIds[$validated['external_id']])) {
$this->warn($label.' | external_id already occurs in this batch with different bytes; identity review required');
continue;
}
$seenExternalIds[$validated['external_id']] = $hash;
$existing = DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) {
$this->line($label.' | already imported by SHA-256; document='.$existing->id.'; no duplicate');
continue;
}
$externalMatch = DB::table('documents')->where('source_system', 'document-batch-import')
->where('external_id', $validated['external_id'])->whereNull('deleted_at')->first();
if ($externalMatch) {
$this->warn($label.' | external_id already belongs to document '.$externalMatch->id.' with different bytes; version review required');
continue;
}
$identity = $this->identity($validated);
$identityMatch = DB::table('portal_resources')->where('document_identity_key', $identity)->first();
if ($identityMatch) {
$this->warn($label.' | identity matches resource '.$identityMatch->id.' but bytes differ; version review required');
continue;
}
$this->line($label.' | '.($apply ? 'will import as private draft' : 'preview: private draft')." | bytes={$validated['size_bytes']} | sha256={$hash}");
if (! $apply) {
continue;
}
$documentId = (string) Str::uuid();
$storageKey = 'portal-documents/'.$documentId;
$source = fopen($validated['source_path'], 'rb');
if (! is_resource($source)) {
throw new RuntimeException('Source could not be opened.');
}
try {
$stored = $disk->put($storageKey, $source);
} finally {
fclose($source);
}
$storedPath = $disk->path($storageKey);
$storedHash = $stored && is_file($storedPath) ? hash_file('sha256', $storedPath) : false;
if (! $stored || ! is_string($storedHash) || ! hash_equals($hash, $storedHash)) {
$disk->delete($storageKey);
throw new RuntimeException('Private copy failed the checksum verification.');
}
try {
$resourceId = DB::transaction(function () use ($validated, $documentId, $storageKey, $hash, $identity, $actorId): int {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch:'.$hash]);
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['document-batch-identity:'.$identity]);
if (DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->exists()) {
throw new RuntimeException('Matching bytes were registered concurrently; rerun preview.');
}
if (DB::table('portal_resources')->where('document_identity_key', $identity)->exists()) {
throw new RuntimeException('A matching document identity was registered concurrently; version review required.');
}
$now = now();
DB::table('documents')->insert([
'id' => $documentId, 'source_system' => 'document-batch-import', 'external_id' => $validated['external_id'],
'version_no' => 1, 'data_classification' => 'internal', 'file_name' => $validated['file_name'],
'media_type' => $validated['media_type'], 'storage_key' => $storageKey, 'sha256' => $hash,
'status' => 'quarantined', 'size_bytes' => $validated['size_bytes'], 'issuer' => $validated['issuer'],
'issued_on' => $validated['document_date'], 'uploaded_by' => (int) $actorId, 'rights_status' => 'unconfirmed',
'original_source' => $validated['source_url'], 'malware_scan_status' => 'pending',
'created_at' => $now, 'updated_at' => $now,
]);
$resourceId = DB::table('portal_resources')->insertGetId([
'document_id' => $documentId, 'title' => $validated['title'], 'category' => $validated['category'],
'topic' => $validated['topic'], 'legislation' => $validated['legislation'], 'essential' => false,
'published' => false, 'source_reference' => $validated['source_reference'], 'approved_by' => null,
'description' => $validated['description'], 'document_type' => $validated['document_type'],
'document_number' => $validated['document_number'], 'document_date' => $validated['document_date'],
'source_url' => $validated['source_url'], 'language' => $validated['language'],
'version_label' => $validated['version_label'], 'territorial_scope' => 'unknown',
'source_confirmation' => 'unconfirmed', 'legal_status' => 'unknown', 'rights_status' => 'unconfirmed',
'document_identity_key' => $identity, 'metadata_version' => 1, 'created_by' => (int) $actorId,
'last_reviewed_by' => null, 'independent_review' => false, 'created_at' => $now, 'updated_at' => $now,
]);
$resource = DB::table('portal_resources')->where('id', $resourceId)->first();
DB::table('portal_resource_revisions')->insert([
'resource_id' => $resourceId, 'revision_no' => 1, 'change_type' => 'created',
'snapshot' => json_encode(['resource' => $resource, 'associations' => []], JSON_THROW_ON_ERROR),
'actor_user_id' => (int) $actorId, 'created_at' => $now,
]);
DB::table('audit_events')->insert([
'id' => (string) Str::uuid(), 'source_system' => 'document-batch-import',
'external_id' => $validated['external_id'], 'data_classification' => 'internal',
'actor_user_id' => (int) $actorId, 'entity_type' => 'document', 'entity_id' => $documentId,
'action' => 'private_document_batch_imported',
'after_data' => json_encode(['sha256' => $hash, 'resource_id' => $resourceId, 'published' => false], JSON_THROW_ON_ERROR),
'occurred_at' => $now, 'created_at' => $now,
]);
return $resourceId;
});
$dispatch[] = $documentId;
$this->info($label.' | private draft created | resource='.$resourceId.'; pending scan; not published');
} catch (Throwable $exception) {
$disk->delete($storageKey);
throw $exception;
}
} catch (Throwable $exception) {
$this->error($label.' | not imported: '.$exception->getMessage());
$failures++;
}
}
foreach ($dispatch as $documentId) {
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
}
$this->line($apply ? 'New scans queued: '.count($dispatch).'.' : 'Preview only; database and storage unchanged.');
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. */
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);
$manifestName = $trio ? 'legacy-trio-20261015/manifest.json' : ($sectors ? 'legacy-setores-lote20-20261015/manifest.json' : 'legacy-agronegocio/manifest.json');
$manifestHash = $trio ? self::TRIO_MANIFEST_SHA256 : ($sectors ? self::SECTORS_MANIFEST_SHA256 : self::PILOT_MANIFEST_SHA256);
if ($manifestPath !== realpath($staging.'/'.$manifestName)
|| ! hash_equals($manifestHash, hash('sha256', $manifestBytes))
|| count($manifest['documents']) !== count($allowed)) {
throw new RuntimeException('Only the exact selected manifest and its fixed resource list are allowed.');
}
$target = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$host = config('database.connections.pgsql.host');
$qa = app()->environment('testing') && str_ends_with($target->database, '_test') && (int) $target->port === 55441;
$active = ! app()->environment('testing') && $target->database === 'investemoz' && (int) $target->port === 5432;
if ((! $qa && ! $active) || ! in_array($host, ['127.0.0.1', 'localhost'], true) || config('database.connections.pgsql.url')) {
throw new RuntimeException('Pilot target identity is not permitted.');
}
if ($apply || $actorId !== null) {
$actor = is_string($actorId) && preg_match('/^[1-9][0-9]*$/D', $actorId) ? User::find((int) $actorId) : null;
if (! $actor || ! $actor->can('manage-portal')) {
throw new RuntimeException('A real existing account with effective manage-portal authorization is required.');
}
}
if ($active && $apply) {
$this->validatePilotBackup($this->option('backup-proof'));
}
if ($trio) {
$images = DB::table('documents as d')->join('portal_resources as r', 'r.document_id', '=', 'd.id')
->where('d.external_id', 'legacy-tempo-image-28f41f12e10ab345')->where('d.sha256', self::TRIO_REUSED_IMAGE_SHA256)
->whereNull('d.deleted_at')->where('d.status', 'active')->where('d.data_classification', 'public')
->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, 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.');
}
}
foreach ($manifest['documents'] as $record) {
$entry = $this->validateRecord($record, $staging);
if (! hash_equals($allowed[$entry['external_id']] ?? '', $entry['sha256'])) {
throw new RuntimeException('Article or image differs from the approved SHA-256.');
}
$existing = DB::table('documents')->where('sha256', $entry['sha256'])->whereNull('deleted_at')->first();
if ($existing && ($existing->external_id !== $entry['external_id']
|| ! DB::table('portal_resources')->where('document_id', $existing->id)->exists())) {
throw new RuntimeException('Matching bytes have another identity or no library resource; reconcile explicitly.');
}
if (DB::table('documents')->where('external_id', $entry['external_id'])->whereNull('deleted_at')
->where('sha256', '!=', $entry['sha256'])->exists()
|| (! $existing && DB::table('portal_resources')->where('document_identity_key', $this->identity($entry))->exists())) {
throw new RuntimeException('Existing identity/version conflicts with this pilot; no overwrite or partial preflight import.');
}
}
}
private function validatePilotBackup(mixed $path): void
{
if (! is_string($path) || ! is_file($path)) {
throw new RuntimeException('Active pilot apply requires a fresh verified backup/restoration receipt.');
}
$proof = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
$verified = strtotime($proof['verified_at'] ?? '');
if (! ($proof['restore_verified'] ?? false) || ! ($proof['files_verified'] ?? false)
|| ($proof['target']['database'] ?? '') !== 'investemoz' || (int) ($proof['target']['port'] ?? 0) !== 5432
|| ! 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'] ?? '', 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.');
}
}
/** @return array<string, mixed> */
private function validateRecord(mixed $record, string $stagingRoot): array
{
if (! is_array($record)) {
throw new RuntimeException('Each documents entry must be an object.');
}
foreach (['external_id', 'relative_path', 'title', 'issuer'] as $required) {
if (! isset($record[$required]) || ! is_string($record[$required]) || trim($record[$required]) === '') {
throw new RuntimeException('Required manifest field is missing: '.$required.'.');
}
}
if (strlen($record['external_id']) > 120 || strlen($record['title']) > 250 || strlen($record['issuer']) > 180) {
throw new RuntimeException('A metadata value exceeds its permitted length.');
}
if (isset($record['file_name']) && (! is_string($record['file_name']) || strlen(basename($record['file_name'])) > 255)) {
throw new RuntimeException('File name exceeds its permitted length.');
}
if (isset($record['document_number']) && (! is_string($record['document_number']) || strlen($record['document_number']) > 120)) {
throw new RuntimeException('Document number exceeds its permitted length.');
}
if (isset($record['document_date'])) {
if (! is_string($record['document_date']) || ! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $record['document_date'], $dateParts)
|| ! checkdate((int) $dateParts[2], (int) $dateParts[3], (int) $dateParts[1])) {
throw new RuntimeException('Document date must use a valid YYYY-MM-DD value.');
}
}
if (isset($record['document_type']) && ! in_array($record['document_type'], ['legislation', 'procedure', 'official_form', 'digital_adaptation', 'guidance_template', 'guide', 'study', 'reference_data', 'unknown'], true)) {
throw new RuntimeException('Document type is not supported by the catalogue.');
}
$path = realpath($stagingRoot.DIRECTORY_SEPARATOR.$record['relative_path']);
if (! $path || ! str_starts_with(strtolower($path), strtolower($stagingRoot.DIRECTORY_SEPARATOR)) || ! is_file($path)) {
throw new RuntimeException('Source file is missing or outside the private staging directory.');
}
$size = filesize($path);
if (! is_int($size) || $size < 1 || $size > self::MAX_BYTES) {
throw new RuntimeException('File size is outside the 1 byte to 10 MiB import limit.');
}
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($path);
$allowed = ['application/pdf', 'application/json', 'text/plain', 'text/csv', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'image/png', 'image/jpeg', 'image/webp'];
if (! is_string($mime) || ! in_array($mime, $allowed, true)) {
throw new RuntimeException('Detected media type is not supported by the document library.');
}
$sha = hash_file('sha256', $path);
if (! is_string($sha)) {
throw new RuntimeException('File hash could not be calculated.');
}
$sourceUrl = $record['source_url'] ?? null;
if ($sourceUrl !== null && (! is_string($sourceUrl) || strlen($sourceUrl) > 2000 || ! filter_var($sourceUrl, FILTER_VALIDATE_URL) || strtolower((string) parse_url($sourceUrl, PHP_URL_SCHEME)) !== 'https')) {
throw new RuntimeException('Source URL, when supplied, must be a valid HTTPS URL.');
}
return [
...$record, 'source_path' => $path, 'file_name' => basename((string) ($record['file_name'] ?? $path)),
'size_bytes' => $size, 'media_type' => $mime, 'sha256' => $sha,
'category' => (string) ($record['category'] ?? 'general'), 'topic' => (string) ($record['topic'] ?? 'unclassified'),
'legislation' => (bool) ($record['legislation'] ?? false), 'document_type' => (string) ($record['document_type'] ?? 'unknown'),
'document_number' => $record['document_number'] ?? null, 'document_date' => $record['document_date'] ?? null,
'language' => (string) ($record['language'] ?? 'unknown'), 'version_label' => $record['version_label'] ?? null,
'source_url' => $record['source_url'] ?? null, 'source_reference' => $record['source_reference'] ?? 'Origem por completar.',
'description' => $record['description'] ?? 'Documento importado para revisão documental.',
];
}
/** @param array<string, mixed> $record */
private function identity(array $record): string
{
return hash('sha256', mb_strtolower(implode('|', array_map('trim', [
(string) $record['issuer'], (string) ($record['document_number'] ?? ''), (string) ($record['document_date'] ?? ''),
(string) $record['language'], (string) ($record['version_label'] ?? $record['title']),
]))));
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Services\InvestmentZones;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
class ImportIndustrialParkCandidates extends Command
{
protected $signature = 'investment-zones:import-industrial-candidates {--apply : Create missing private drafts} {--actor= : Existing eligible manager user ID required with --apply}';
protected $description = 'Reconcile and optionally create source-backed industrial-park/ZFI drafts without publishing';
public function handle(): int
{
$document = json_decode($this->readSource(database_path('data/industrial-park-candidate-drafts.json')), true, flags: JSON_THROW_ON_ERROR);
if (($document['source_document_attached'] ?? null) !== true
|| ($document['source_document_reconciled'] ?? null) !== true) {
$this->error('The source document must be attached and reconciled before importing drafts. No records written.');
return self::FAILURE;
}
$rows = [];
foreach ($document['records'] as $record) {
$existing = DB::table('investment_zones')->where('code', $record['code'])->first(['id', 'name', 'publication_status']);
$territories = [];
foreach ($record['territories'] as $territory) {
$expected = $this->normalize($territory['name']);
$matches = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $territory['type'])
->get(['id', 'name'])->filter(fn ($node) => $this->normalize($node->name) === $expected);
if ($matches->count() !== 1) {
throw new RuntimeException("Territory {$territory['name']} ({$territory['type']}) matched {$matches->count()} rows; approximate matching is disabled.");
}
$territories[] = [...$territory, 'geo_location_id' => $matches->first()->id, 'resolved_name' => $matches->first()->name];
}
$rows[] = ['record' => $record, 'existing' => $existing, 'territories' => $territories];
}
$this->table(['Code', 'Action', 'Territory IDs resolved', 'Areas'], array_map(fn ($row) => [
$row['record']['code'], $row['existing'] ? 'reconcile/skip-existing' : ($this->option('apply') ? 'create-private-draft' : 'would-create-private-draft'),
implode('; ', array_map(fn ($t) => $t['resolved_name'], $row['territories'])), count($row['record']['areas']),
], $rows));
if (! $this->option('apply')) {
$this->info('Dry run only. No writes made; every created record remains a private draft, with legal regime/availability not implied by the source.');
return self::SUCCESS;
}
if (! Schema::hasColumn('investment_zones', 'source_evidence')) {
$this->error('The additive industrial-park schema is not migrated in this database. No records written.');
return self::FAILURE;
}
$actor = filter_var($this->option('actor'), FILTER_VALIDATE_INT);
$user = $actor ? DB::table('users')->where('id', $actor)->first(['id', 'email', 'email_verified_at', 'two_factor_confirmed_at']) : null;
if (! $user || ! $user->email_verified_at || ! $user->two_factor_confirmed_at
|| ! in_array(mb_strtolower($user->email), config('portal.managers', []), true)) {
$this->error('An existing verified, MFA-enabled configured manager is required. No records written.');
return self::FAILURE;
}
$created = DB::transaction(function () use ($rows, $actor, $document): array {
$created = [];
foreach ($rows as $row) {
if ($row['existing']) {
continue;
}
$record = $row['record'];
$source = $record['source'];
$sourceReference = $source['url'].' — '.$source['title'];
$locations = array_map(fn ($location) => ['geo_location_id' => $location['geo_location_id'], 'scope_type' => $location['scope_type'],
'source_reference' => $sourceReference, 'legal_source_id' => null], $row['territories']);
$first = $row['territories'][0]['geo_location_id'] ?? null;
$zone = app(InvestmentZones::class)->save(null, [
'code' => $record['code'], 'name' => $record['name'], 'category' => $record['category'],
'physical_category' => $record['physical_category'], 'legal_regime' => $record['legal_regime'],
'development_status' => $record['development_status'], 'availability_status' => $record['availability_status'],
'operational_status' => 'unknown', 'description' => $record['source']['supports'].(! empty($record['territory_note']) ? ' '.$record['territory_note'] : ''),
'source_evidence' => [[...$source, 'source_date' => null]],
'area_components' => array_map(fn ($area) => [...$area, 'source_reference' => $sourceReference.' — '.$area['source_reference'], 'source_date' => null], $record['areas']),
'area_value' => null, 'area_unit' => null, 'activities' => [], 'resources' => [], 'aliases' => [],
'sources_consulted_at' => $document['consulted_at'],
'legal_source_id' => null, 'parent_zone_id' => null, 'primary_territory_id' => $first,
'territory_version' => $first ? 1 : 0, 'locations' => $locations,
], (int) $actor);
$created[] = [$zone->code, $zone->id];
}
return $created;
});
$this->info('Private drafts created: '.count($created).'. Existing codes skipped. No legal sources were approved and no records were published.');
foreach ($created as [$code, $id]) {
$this->line($code.' '.$id);
}
return self::SUCCESS;
}
private function normalize(string $value): string
{
return Str::upper(Str::ascii(trim($value)));
}
private function readSource(string $path): string
{
$bytes = file_get_contents($path);
if ($bytes === false) {
throw new RuntimeException('Source file is unavailable: '.$path);
}
return $bytes;
}
}
@@ -5,54 +5,18 @@ namespace App\Console\Commands;
use App\Models\InvestmentDossier;
use Database\Seeders\InvestmentPortfolioSeeder;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class ImportInvestmentPortfolio extends Command
{
protected $signature = 'portfolio:import {--approval= : Recorded approval from the document owner}';
protected $signature = 'portfolio:import';
protected $description = 'Import the v4 portfolio and optionally publish unchanged documentary dossiers with owner approval';
protected $description = 'Import the documentary portfolio as private drafts; publication uses the authenticated review workflow';
public function handle(): int
{
$approval = trim((string) $this->option('approval'));
if ($approval !== '' && (mb_strlen($approval) < 15 || mb_strlen($approval) > 3000)) {
$this->error('A autorização deve conter entre 15 e 3000 caracteres.');
return self::FAILURE;
}
$data = json_decode(File::get(database_path('data/investment-portfolio.json')), true, 512, JSON_THROW_ON_ERROR);
$published = 0;
$preserved = 0;
DB::transaction(function () use ($data, $approval, &$published, &$preserved) {
app(InvestmentPortfolioSeeder::class)->run();
foreach (['projects' => ['project_id', 'projects'], 'clusters' => ['cluster_id', 'economic_clusters'], 'corridors' => ['corridor_id', 'development_corridors']] as $kind => [$foreign, $table]) {
foreach ($data[$kind] as $item) {
$entity = DB::table($table)->where('code', $item['code'])->where('source_system', 'upgrade-v4')->whereNull('deleted_at')->value('id');
$dossier = InvestmentDossier::where($foreign, $entity)->lockForUpdate()->first();
// Approval of this document never approves subsequent human edits or restores archived records.
if (! $dossier || $dossier->title !== $item['name'] || $dossier->content != [...$item, 'notice' => $data['notice']] || $dossier->editor_id) {
$preserved++;
continue;
}
if ($approval === '' || $dossier->publication_status === 'published') {
continue;
}
$dossier->update(['version_no' => $dossier->version_no + 1, 'publication_status' => 'published', 'published_at' => now(),
'approval_basis' => 'document_owner', 'approval_evidence' => $approval]);
DB::table('audit_events')->insert(['source_system' => 'portfolio-import', 'entity_type' => 'investment_dossiers',
'entity_id' => $dossier->id, 'action' => 'owner_approved', 'after_data' => json_encode([
'code' => $item['code'], 'approval_basis' => 'document_owner',
'approval_sha256' => hash('sha256', $approval),
'dataset_sha256' => hash_file('sha256', database_path('data/investment-portfolio.json')),
], JSON_THROW_ON_ERROR)]);
$published++;
}
}
});
$this->info("Carteira importada: 15 projectos, 8 clusters, 6 corredores. Publicados: {$published}. Fichas alteradas/arquivadas preservadas: {$preserved}.");
app(InvestmentPortfolioSeeder::class)->run();
$drafts = InvestmentDossier::where('publication_status', 'draft')->count();
$this->info("Carteira importada/actualizada. Dossiers em rascunho privado: {$drafts}. Nenhuma publicação foi realizada.");
return self::SUCCESS;
}
@@ -0,0 +1,133 @@
<?php
namespace App\Console\Commands;
use App\Services\InvestmentZones;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class ImportInvestmentZones extends Command
{
protected $signature = 'investment-zones:import-initial {--apply : Create missing records as private drafts} {--actor= : Existing manager user ID required with --apply}';
protected $description = 'Import documented initial ZEE, ZFI and industrial park references without overwriting or publishing existing records';
public function handle(): int
{
$path = database_path('data/investment-zones-initial.json');
$document = json_decode($this->readSource($path), true, flags: JSON_THROW_ON_ERROR);
$rows = [];
foreach ($document['records'] as $record) {
$existing = DB::table('investment_zones')->where('code', $record['code'])->first(['id', 'name', 'category', 'publication_status']);
$territories = [];
foreach ($record['territories'] as $territory) {
$expected = $this->normalize($territory['name']);
$matches = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $territory['type'])
->get(['id', 'name', 'data_classification'])->filter(fn ($node) => $this->normalize($node->name) === $expected);
if ($matches->count() !== 1) {
throw new RuntimeException("Territory reference {$territory['name']} ({$territory['type']}) matched {$matches->count()} master records; no approximate match or new territory was created.");
}
$territories[] = [...$territory, 'geo_location_id' => $matches->first()->id, 'resolved_name' => $matches->first()->name];
}
$rows[] = ['code' => $record['code'], 'name' => $record['name'], 'action' => $existing ? 'skip-existing' : ($this->option('apply') ? 'create-draft' : 'would-create-draft'), 'existing' => $existing, 'territories' => $territories];
}
if (! $this->option('apply')) {
$this->table(['Code', 'Name', 'Action', 'Territories'], array_map(fn ($row) => [$row['code'], $row['name'], $row['action'], implode('; ', array_column($row['territories'], 'resolved_name'))], $rows));
$this->info('Dry run: no records written. Sources are official references; records remain private drafts pending review.');
return self::SUCCESS;
}
$actor = filter_var($this->option('actor'), FILTER_VALIDATE_INT);
$user = $actor ? DB::table('users')->where('id', $actor)->first(['id', 'email', 'email_verified_at', 'two_factor_confirmed_at']) : null;
if (! $user || ! $user->email_verified_at || ! $user->two_factor_confirmed_at
|| ! in_array(mb_strtolower($user->email), config('portal.managers', []), true)) {
$this->error('An existing, verified, MFA-enabled portal manager is required with --apply --actor=.');
return self::FAILURE;
}
$summary = DB::transaction(function () use ($rows, $document, $actor): array {
$created = [];
$skipped = [];
foreach ($rows as $row) {
if ($row['existing']) {
$skipped[] = $row['code'];
continue;
}
$record = collect(is_array($document['records'] ?? null) ? $document['records'] : [])->firstWhere('code', $row['code']);
$requiresLegalSource = in_array($record['category'], ['zee', 'zfi'], true);
$source = $requiresLegalSource
? DB::table('investment_legal_sources')->where('url', $record['source']['url'])->where('diploma', $record['source']['diploma'])->first()
: null;
$sourceId = $requiresLegalSource ? ($source->id ?? (string) Str::uuid()) : null;
if ($requiresLegalSource && ! $source) {
DB::table('investment_legal_sources')->insert([
'id' => $sourceId, ...$record['source'], 'validation_status' => 'pending', 'owner_id' => $actor,
'version_no' => 1, 'created_at' => now(), 'updated_at' => now(),
]);
}
$parentId = null;
if (isset($record['parent_code'])) {
$parentId = DB::table('investment_zones')->where('code', $record['parent_code'])->value('id');
if (! $parentId) {
throw new RuntimeException("Parent zone {$record['parent_code']} must be imported before {$record['code']}.");
}
}
$locations = array_map(fn ($location) => [
'geo_location_id' => $location['geo_location_id'], 'scope_type' => $location['scope_type'],
'source_reference' => $record['source']['url'].' — '.($record['source']['diploma'] ?? $record['source']['title']), 'legal_source_id' => $sourceId,
], $row['territories']);
$firstLocation = $row['territories'][0]['geo_location_id'] ?? null;
$zone = app(InvestmentZones::class)->save(null, [
'code' => $record['code'], 'category' => $record['category'], 'physical_category' => $record['physical_category'],
'legal_regime' => $record['legal_regime'], 'operational_status' => $record['operational_status'],
'development_status' => $record['development_status'] ?? match ($record['operational_status']) {
'operational' => 'operational', 'implementation' => 'in_development', 'planned' => 'planned', default => 'unknown'
},
'availability_status' => $record['availability_status'] ?? 'not_informed',
'name' => $record['name'], 'name_en' => $record['name_en'], 'description' => $record['description'], 'description_en' => $record['description_en'],
'area_value' => $record['area_value'], 'area_unit' => $record['area_unit'], 'area_components' => $record['area_components'] ?? [],
'source_evidence' => [[
'title' => $record['source']['title'], 'url' => $record['source']['url'],
'source_type' => str_contains($record['source']['url'], 'inm.gov.mz') ? 'government' : (str_contains($record['source']['url'], 'apiex.gov.mz') ? 'institutional' : 'promoter'),
'supports' => $record['source']['supports'] ?? 'Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade.',
'source_date' => $record['source']['source_date'] ?? null,
]], 'aliases' => $record['aliases'],
'activities' => $record['activities'], 'resources' => [], 'historical_territory' => $record['historical_territory'] ?? null,
'data_as_of' => null, 'sources_consulted_at' => $document['consulted_at'], 'operator_id' => null,
'legal_source_id' => $sourceId, 'parent_zone_id' => $parentId, 'primary_territory_id' => $firstLocation,
'territory_version' => $firstLocation ? 1 : 0, 'province' => null, 'district' => null, 'locations' => $locations,
], (int) $actor);
$created[] = ['code' => $record['code'], 'id' => $zone->id, 'source' => $source?->id ? 'reused' : 'created', 'locations_pending' => count($locations)];
}
return compact('created', 'skipped');
});
$this->table(['Code', 'ID', 'Source', 'Pending locations'], array_map(fn ($row) => [$row['code'], $row['id'], $row['source'], $row['locations_pending']], $summary['created']));
$this->info('Created drafts: '.count($summary['created']).'; skipped without overwrite: '.count($summary['skipped']).'; publication: none.');
$this->line('Skipped: '.implode(', ', $summary['skipped']));
return self::SUCCESS;
}
private function normalize(string $value): string
{
return Str::upper(Str::ascii(trim($value)));
}
private function readSource(string $path): string
{
$bytes = file_get_contents($path);
if ($bytes === false) {
throw new RuntimeException('Source file is unavailable: '.$path);
}
return $bytes;
}
}
@@ -0,0 +1,312 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
class ImportLegalSourceArchive extends Command
{
protected $signature = 'documents:import-legal-source-archive {--apply : Copy verified archive files into private storage and register draft metadata}';
protected $description = 'Idempotently imports the known local legal-source PDFs as private, unpublished library drafts';
public function handle(): int
{
$records = $this->records();
$apply = (bool) $this->option('apply');
$disk = Storage::disk('local');
$dispatch = [];
$failures = 0;
foreach ($records as $record) {
$sourcePath = base_path($record['archive_path']);
$realSource = realpath($sourcePath);
$approvedRoots = array_filter([
realpath(base_path('docs/investmoz/simulations/source-archive')),
realpath(storage_path('app/private/geo-ine-source-20260928')),
]);
if (! $realSource && ($record['optional'] ?? false)) {
$this->warn($record['external_id'].': approved local source copy is not present; skipped without changing records.');
continue;
}
$withinApprovedRoot = $realSource && collect($approvedRoots)->contains(fn (string $root) => str_starts_with(strtolower($realSource), strtolower($root.DIRECTORY_SEPARATOR)));
if (! $realSource || ! $withinApprovedRoot || ! is_file($realSource)) {
$this->error($record['external_id'].': archive file missing or outside the approved source archive.');
$failures++;
continue;
}
$hash = hash_file('sha256', $realSource);
$size = filesize($realSource);
$header = file_get_contents($realSource, false, null, 0, 5);
if (! is_string($hash) || ! hash_equals($record['expected_sha256'], strtolower($hash)) || $size !== $record['size_bytes'] || $header !== '%PDF-') {
$this->error($record['external_id'].': checksum, size, or PDF signature differs from the reviewed manifest.');
$failures++;
continue;
}
$identity = hash('sha256', mb_strtolower(implode('|', array_map('trim', [$record['issuer'], $record['document_number'], $record['document_date'], $record['language'], $record['version_label']]))));
$existing = DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->first();
if ($existing) {
$storedPath = $disk->path($existing->storage_key);
$storedReal = realpath($storedPath);
$storageRoot = realpath($disk->path(''));
if (! $storedReal || ! $storageRoot || ! str_starts_with(strtolower($storedReal), strtolower($storageRoot.DIRECTORY_SEPARATOR))
|| ! is_file($storedReal) || ! hash_equals($hash, (string) hash_file('sha256', $storedReal))) {
$this->error($record['external_id'].': matching hash exists but the stored copy could not be verified; no changes made.');
$failures++;
continue;
}
$resource = DB::table('portal_resources')->where('document_id', $existing->id)->first();
if (! $resource) {
$this->error($record['external_id'].': document hash already exists without its expected library record; manual reconciliation required.');
$failures++;
continue;
}
if (! hash_equals($identity, (string) $resource->document_identity_key)) {
$this->error($record['external_id'].': identical bytes exist under a different document identity; manual reconciliation required.');
$failures++;
continue;
}
$missingEvidence = collect(is_array($record['associations'] ?? null) ? $record['associations'] : [])->filter(fn (array $association) => $association['target_type'] === 'requirement')
->reject(fn (array $association) => DB::table('portal_resource_links')->where('resource_id', $resource->id)
->where('target_type', 'requirement')->where('target_key', $association['target_key'])->exists())->values();
if ($apply && $missingEvidence->isNotEmpty()) {
DB::transaction(function () use ($resource, $missingEvidence): void {
$locked = DB::table('portal_resources')->where('id', $resource->id)->lockForUpdate()->first();
$now = now();
foreach ($missingEvidence as $association) {
DB::table('portal_resource_links')->insertOrIgnore(['resource_id' => $resource->id, ...$association,
'created_at' => $now, 'updated_at' => $now]);
}
$inserted = DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement')
->whereIn('target_key', $missingEvidence->pluck('target_key'))->count();
if ($inserted > 0) {
$version = (int) $locked->metadata_version + 1;
DB::table('portal_resources')->where('id', $resource->id)->update(['metadata_version' => $version, 'updated_at' => $now]);
$links = DB::table('portal_resource_links')->where('resource_id', $resource->id)->orderBy('id')->get();
$snapshot = DB::table('portal_resources')->where('id', $resource->id)->first();
DB::table('portal_resource_revisions')->insert(['resource_id' => $resource->id, 'revision_no' => $version,
'change_type' => 'evidence_linked',
'snapshot' => json_encode(['resource' => $snapshot, 'associations' => $links], JSON_THROW_ON_ERROR),
'actor_user_id' => null, 'created_at' => $now]);
}
});
}
$this->line($record['external_id'].' | reused/reconciled | document='.$existing->id.' | published='.($resource->published ? 'yes' : 'no'));
continue;
}
$identityConflict = DB::table('portal_resources')->where('document_identity_key', $identity)->exists();
if ($identityConflict) {
$this->error($record['external_id'].': document identity exists with different bytes; manual version reconciliation required.');
$failures++;
continue;
}
$this->line($record['external_id'].' | '.($apply ? 'importing private draft' : 'would import')." | sha256={$hash} | bytes={$size}");
if (! $apply) {
continue;
}
$documentId = (string) Str::uuid();
$storageKey = 'portal-documents/'.$documentId;
$sourceHandle = fopen($realSource, 'rb');
if (! is_resource($sourceHandle)) {
$this->error($record['external_id'].': source could not be opened.');
$failures++;
continue;
}
try {
$stored = $disk->put($storageKey, $sourceHandle);
} finally {
fclose($sourceHandle);
}
$storedPath = $disk->path($storageKey);
$storedHash = $stored && is_file($storedPath) ? hash_file('sha256', $storedPath) : false;
if (! $stored || ! is_string($storedHash) || ! hash_equals($hash, $storedHash)) {
$disk->delete($storageKey);
$this->error($record['external_id'].': private copy failed the post-copy checksum; no database record created.');
$failures++;
continue;
}
try {
$result = DB::transaction(function () use ($record, $documentId, $storageKey, $hash, $size, $identity): array {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['legal-source-archive:'.$hash]);
if (DB::table('documents')->where('sha256', $hash)->whereNull('deleted_at')->exists()) {
throw new RuntimeException('A matching document was registered concurrently; rerun the import to reconcile it.');
}
if (DB::table('portal_resources')->where('document_identity_key', $identity)->exists()) {
throw new RuntimeException('A matching document identity was registered concurrently; manual reconciliation required.');
}
$now = now();
DB::table('documents')->insert([
'id' => $documentId, 'source_system' => 'legal-source-archive', 'external_id' => $record['external_id'],
'version_no' => 1, 'data_classification' => 'internal', 'file_name' => $record['file_name'],
'media_type' => 'application/pdf', 'storage_key' => $storageKey, 'sha256' => $hash,
'status' => 'quarantined', 'size_bytes' => $size, 'issuer' => $record['issuer'],
'original_source' => $record['source_url'], 'issued_on' => $record['document_date'],
'uploaded_by' => null, 'rights_status' => 'unconfirmed', 'malware_scan_status' => 'pending',
'created_at' => $now, 'updated_at' => $now,
]);
$resourceId = DB::table('portal_resources')->insertGetId([
'document_id' => $documentId, 'title' => $record['title'], 'category' => $record['category'] ?? 'legislation',
'topic' => $record['topic'], 'legislation' => $record['is_legislation'] ?? true, 'essential' => false, 'published' => false,
'source_reference' => $record['source_reference'], 'approved_by' => null, 'description' => $record['description'],
'document_type' => $record['document_type'] ?? 'legislation', 'document_number' => $record['document_number'],
'document_date' => $record['document_date'], 'source_url' => $record['source_url'],
'language' => $record['language'], 'version_label' => $record['version_label'],
'territorial_scope' => 'national', 'source_confirmation' => $record['source_confirmation'],
'legal_status' => $record['legal_status'], 'rights_status' => 'unconfirmed', 'last_reviewed_at' => null,
'document_identity_key' => $identity, 'metadata_version' => 1, 'created_by' => null,
'last_reviewed_by' => null, 'independent_review' => false, 'created_at' => $now, 'updated_at' => $now,
]);
foreach ($record['associations'] as $association) {
$association['review_status'] ??= 'pending';
DB::table('portal_resource_links')->insertOrIgnore([
'resource_id' => $resourceId, ...$association, 'created_at' => $now, 'updated_at' => $now,
]);
}
$links = DB::table('portal_resource_links')->where('resource_id', $resourceId)->orderBy('id')->get();
$resource = DB::table('portal_resources')->where('id', $resourceId)->first();
DB::table('portal_resource_revisions')->insert([
'resource_id' => $resourceId, 'revision_no' => 1, 'change_type' => 'created',
'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR),
'actor_user_id' => null, 'created_at' => $now,
]);
return ['document_id' => $documentId, 'resource_id' => $resourceId];
});
$dispatch[] = $result['document_id'];
$this->info($record['external_id'].' | created private draft | document='.$result['document_id'].' | resource='.$result['resource_id']);
} catch (Throwable $exception) {
$disk->delete($storageKey);
$this->error($record['external_id'].': import rolled back ('.$exception->getMessage().').');
$failures++;
}
}
foreach ($dispatch as $documentId) {
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
}
$this->line($apply ? 'New malware scans queued: '.count($dispatch).'.' : 'Preview only; no files or database rows were changed.');
return $failures > 0 ? self::FAILURE : self::SUCCESS;
}
/** @return array<int, array<string, mixed>> */
private function records(): array
{
$simulatorEvidence = fn (string $basis): array => [
['target_type' => 'module', 'target_key' => 'simulator', 'relationship' => 'supports', 'applicability' => 'evidence',
'condition_text' => 'Consulta documental; a fonte não determina por si só a regra fiscal aplicável.', 'basis_reference' => $basis],
];
return [
[
'external_id' => 'inm-br250-supplement-2-law-22-2022-v1',
'archive_path' => 'docs/investmoz/simulations/source-archive/Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf',
'file_name' => 'Lei-22-2022-IVA-republicada-BR-250-2-suplemento.pdf',
'expected_sha256' => 'ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53', 'size_bytes' => 1834284,
'title' => 'Lei n.º 22/2022, de 28 de Dezembro — alteração ao Código do IVA (republicação)',
'document_number' => 'Lei n.º 22/2022', 'document_date' => '2022-12-28', 'issuer' => 'Assembleia da República',
'language' => 'pt', 'version_label' => '2.º Suplemento do BR n.º 250/2022; diploma alterador, não consolidação integral',
'topic' => 'iva', 'legal_status' => 'amended', 'source_confirmation' => 'official_confirmed',
'source_url' => 'https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-suplemento-n%C2%BA-2-de-281222-pag-2252-25-42-br-n%C2%BA-250-boletim-da',
'source_reference' => 'INM, 2.º Suplemento do BR n.º 250 e catálogo oficial AT, registo Lei 22 IVA 2022. O ficheiro local foi descarregado novamente do endpoint AT em 2026-10-06; tamanho e SHA-256 coincidem exactamente. O artigo 17(1) está citado na revisão documental.',
'description' => 'Diploma alterador do CIVA, sem afirmar consolidação ou vigência integral actual. O texto oficial está identificado; direitos de reprodução da composição PDF continuam por rever, pelo que o ficheiro permanece privado.',
'associations' => [...$simulatorEvidence('Lei n.º 22/2022, art. 17(1), conforme revisão documental em docs/investmoz/simulations/primary-text-review-2025-2026.md; consultar alterações posteriores antes de determinar o período.'),
['target_type' => 'theme', 'target_key' => 'iva', 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null,
'basis_reference' => 'Lei n.º 22/2022, art. 17(1); efeito temporal e alterações posteriores devem ser considerados.'],
['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_22_2022', 'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'confirmed',
'condition_text' => 'Evidência documental; a citação não activa nem determina cálculos fiscais.', 'basis_reference' => 'Lei n.º 22/2022, art. 17(1), p. 3 do PDF oficial arquivado; a redacção diz 16%. Conferência da citação concluída; aplicação temporal e aprovação fiscal continuam separadas.']],
],
[
'external_id' => 'inm-br248-supplement-1-2025-v1',
'archive_path' => 'docs/investmoz/simulations/source-archive/BR-248-I-Suplemento-1-2025.pdf',
'file_name' => 'BR-248-I-Suplemento-1-2025.pdf',
'expected_sha256' => 'ccf2a8deeca820f2dd076b4b2141b1efe3f6a806568c3a587ca59a8e92f4c251', 'size_bytes' => 440018,
'title' => 'Boletim da República n.º 248/2025 — Suplemento n.º 1',
'document_number' => 'BR n.º 248/2025, Suplemento n.º 1', 'document_date' => '2025-12-29', 'issuer' => 'Imprensa Nacional de Moçambique',
'language' => 'pt', 'version_label' => 'Suplemento n.º 1; edição arquivada com 21 páginas',
'topic' => 'fiscalidade', 'legal_status' => 'unknown', 'source_confirmation' => 'official_confirmed',
'source_url' => 'https://inm.gov.mz/pt-br/content/suplemento-n%C2%BA-1-de-291225-pag-2180-1-20-br-n%C2%BA-248-boletim-da-rep%C3%BAblica-i-serie',
'source_reference' => 'Ficha INM do Suplemento n.º 1 do BR 248 e catálogo oficial AT. O ficheiro local foi descarregado novamente do endpoint WebDAV AT em 2026-10-06; tamanho e SHA-256 coincidem exactamente. Inclui diplomas fiscais identificados no registo de fontes; artigo, vigência e situação de cada lei devem ser consultados individualmente.',
'description' => 'Edição que reúne diplomas sobre ICE, Pauta Aduaneira, ISPC, IVA, IRPS e IRPC. O estado de vigência é mantido por confirmar por diploma e dispositivo. O texto oficial está identificado; direitos de reprodução da composição PDF continuam por rever, pelo que o ficheiro permanece privado.',
'associations' => [...$simulatorEvidence('BR n.º 248/2025, Suplemento n.º 1; esta ligação é evidência documental, não confirmação de vigência ou obrigação.'),
...array_map(fn (string $theme) => ['target_type' => 'theme', 'target_key' => $theme, 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null,
'review_status' => 'pending', 'basis_reference' => 'Associação temática antiga; artigo e alcance por diploma ainda não foram especificados.'],
['ice', 'aduaneiro', 'ispc', 'iva', 'irps', 'irpc']),
...array_map(fn (array $item) => ['target_type' => 'requirement', 'target_key' => $item[0], 'relationship' => 'cites',
'applicability' => 'evidence', 'review_status' => 'confirmed', 'condition_text' => 'Citação localizada no texto primário; não confirma interpretação, vigência integral, elegibilidade nem regra executável.',
'basis_reference' => $item[1].' — '.$item[2].' BR n.º 248/2025, Suplemento n.º 1, Lei de 29-12-2025.'], [
['fiscal.ice.lei_7_2025', 'Lei n.º 7/2025 (ICE)', 'arts. 1–5 e tabela anexa; pp. 1–2. Art. 1 prorroga as taxas para 2027; art. 5 indica entrada em vigor em 01-01-2026.'],
['fiscal.customs.lei_8_2025', 'Lei n.º 8/2025 (Pauta Aduaneira)', 'art. 1 altera as Instruções Preliminares, arts. 21(2), 22 e 27; arts. 2–5 e tabela anexa; pp. 2–7.'],
['fiscal.ispc.lei_9_2025', 'Lei n.º 9/2025 (ISPC)', 'arts. 1–7 e republicação anexa da Lei n.º 5/2009; pp. 8–11.'],
['fiscal.iva.lei_10_2025', 'Lei n.º 10/2025 (IVA)', 'arts. 1–4; alterações do CIVA identificadas no diploma, incluindo arts. 18, 19, 21, 23, 25-A, 27 e 32–34; pp. 12–15.'],
['fiscal.irps.lei_11_2025', 'Lei n.º 11/2025 (IRPS)', 'arts. 1–4; alterações do CIRPS, incluindo arts. 21, 22, 48, 50, 54-A, 57, 65 e 65-A; pp. 16–18.'],
['fiscal.irpc.lei_12_2025', 'Lei n.º 12/2025 (IRPC)', 'arts. 1–4; alterações do CIRPC, incluindo arts. 3, 5, 20, 62, 67, 75 e aditamento do art. 61-A; pp. 19–20.'],
]),
],
],
[
'external_id' => 'inm-br89-series-1-2017-iva-rectification-v1',
'archive_path' => 'docs/investmoz/simulations/source-archive/BR-89-I-Serie-2017-06-08.pdf',
'file_name' => 'BR-89-I-Serie-2017-06-08.pdf',
'expected_sha256' => '11f0cab15eb6e51010952203ffa24fdc533a24fd642e19525fe4f46b17a1f5e5', 'size_bytes' => 9456358,
'title' => 'Boletim da República n.º 89, I Série (08-06-2017) — aviso de rectificação da Lei n.º 13/2016',
'document_number' => 'BR n.º 89/2017', 'document_date' => '2017-06-08', 'issuer' => 'Imprensa Nacional de Moçambique',
'language' => 'pt', 'version_label' => 'Exemplar local de 16 páginas; autenticidade binária pendente',
'topic' => 'iva', 'legal_status' => 'unknown', 'source_confirmation' => 'identified',
'source_url' => 'https://www.inm.gov.mz/pt-br/content/assembleia-da-rep%C3%BAblica-br-n%C2%BA-89-de-080617-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-569',
'source_reference' => 'Ficha oficial INM identifica o aviso de rectificação da Lei n.º 13/2016 na p. 569. O exemplar local não tem assinatura digital validável; cadeia de custódia e efeito temporal por confirmar, conforme docs/investmoz/simulations/official-source-register.md.',
'description' => 'Aviso de rectificação ligado ao CIVA de 2016. A identidade do aviso é corroborada pela ficha INM; autenticidade do ficheiro local, anexos corrigidos e efeito temporal permanecem por confirmar. Cópia mantida em privado.',
'associations' => [...$simulatorEvidence('Ficha INM: aviso de rectificação da Lei n.º 13/2016, p. 569; exemplar local e efeito temporal por confirmar.'),
['target_type' => 'theme', 'target_key' => 'iva', 'relationship' => 'cites', 'applicability' => 'evidence', 'condition_text' => null, 'review_status' => 'pending',
'basis_reference' => 'Aviso de rectificação da Lei n.º 13/2016, BR n.º 89/2017, p. 569; exemplar local, anexos corrigidos e efeito temporal ainda não autenticados.'],
['target_type' => 'requirement', 'target_key' => 'fiscal.iva.lei_13_2016_rectification_2017', 'relationship' => 'cites', 'applicability' => 'evidence', 'review_status' => 'pending',
'condition_text' => 'Relação candidata pendente de conferência da cópia e dos anexos rectificados.',
'basis_reference' => 'Aviso de rectificação da Lei n.º 13/2016, BR n.º 89/2017, p. 569; ficha INM identifica o aviso, mas a cópia local e os anexos não foram autenticados.']],
],
[
'external_id' => 'ine-codificador-divisao-politico-administrativa-2012-v1',
'optional' => true,
'archive_path' => 'storage/app/private/geo-ine-source-20260928/original.pdf',
'file_name' => 'INE-Codificador-Divisao-Politico-Administrativa-2012.pdf',
'expected_sha256' => '5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2', 'size_bytes' => 67067,
'title' => 'Codificador da Divisão Político-Administrativa (INE, 07-06-2012)',
'document_number' => 'Codificador DPA 2012', 'document_date' => '2012-06-07', 'issuer' => 'Instituto Nacional de Estatística (INE)',
'language' => 'pt', 'version_label' => 'Edição 2012; actualidade das designações por confirmar',
'topic' => 'território', 'category' => 'Dados de referência', 'document_type' => 'reference_data',
'is_legislation' => false, 'legal_status' => 'unknown', 'source_confirmation' => 'official_confirmed',
'source_url' => 'https://www.ine.gov.mz/documents/20119/44337/Codificador%20da%20Divisao%20Politico%20administrativa.pdf/14aed3d0-9296-c038-dbac-041a5cf40f1d',
'source_reference' => 'PDF disponibilizado no portal oficial do INE; download efectuado em 2026-10-06, 67.067 bytes. SHA-256 coincidente com o original privado e com a cópia oficial obtida no portal INE. A data impressa é 07-06-2012; a vigência territorial actual e os direitos de reutilização do ficheiro permanecem por confirmar.',
'description' => 'Documento de referência com códigos e designações político-administrativas na edição indicada. A extracção anterior encontrou colunas com alinhamento ambíguo, sem autorizar correspondências automáticas de postos e distritos; não usar esta edição como catálogo territorial actual nem como fonte de relações não revistas.',
'territorial_scope' => 'national', 'associations' => [],
],
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
class ImportTerritoryWorkbook extends Command
{
protected $signature = 'geo:import-workbook {--inspect : Inspect the authorised workbook without database writes} {--python=python : Python executable}';
protected $description = 'Inspect and idempotently import the authorised cartography workbook, preserving existing IDs';
public function handle(): int
{
if (! app()->environment('local', 'testing')) {
$this->error('Local/testing only.');
return self::FAILURE;
}
foreach ([[strval($this->option('python')), base_path('tools/prepare-territory-workbook.py')],
...($this->option('inspect') ? [] : [[PHP_BINARY, base_path('tools/import-territory-workbook.php')]])] as $command) {
$process = new Process($command, base_path());
$process->setTimeout(300);
$process->run();
$this->output->write($process->getOutput());
if (! $process->isSuccessful()) {
$this->error($process->getErrorOutput());
return self::FAILURE;
}
}
return self::SUCCESS;
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class ReconcileMneCompatibility extends Command
{
protected $signature = 'mne:reconcile-compatibility {--fail-on-difference : Return failure while any reconciliation or activation gate remains open}';
protected $description = 'Read-only compatibility counts, fingerprints and activation blockers';
public function handle(): int
{
$report = DB::transaction(function (): array {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$domains = [];
foreach (['identity' => ['users', 'identity_links', 'user_id'], 'sector' => ['sectors', 'sector_links', 'sector_id'],
'business' => ['existing_businesses', 'business_links', 'legacy_business_id']] as $domain => [$source, $target, $key]) {
$checksums = [];
$stale = 0;
$missing = 0;
foreach (DB::table('public.'.$source)->orderBy('id')->cursor() as $row) {
$checksum = hash('sha256', json_encode((array) $row, JSON_THROW_ON_ERROR));
$checksums[] = $checksum;
$link = DB::table('mne.'.$target)->where($key, $row->id)->first();
$missing += $link === null ? 1 : 0;
$stale += $link !== null && $link->source_checksum !== $checksum ? 1 : 0;
}
$domains[$domain] = ['source_count' => count($checksums), 'link_count' => DB::table('mne.'.$target)->count(),
'pending_count' => DB::table('mne.'.$target)->where('status', '<>', 'active')->count(),
'missing' => $missing, 'stale_checksums' => $stale, 'checksum' => hash('sha256', implode('', $checksums))];
}
$role = DB::selectOne('SELECT rolsuper,rolcreaterole FROM pg_roles WHERE rolname=current_user');
return ['domains' => $domains, 'unresolved_rejections' => DB::table('mne.compatibility_rejections')->whereNull('resolved_at')->count(),
'runtime_is_superuser' => (bool) $role->rolsuper, 'runtime_can_create_roles' => (bool) $role->rolcreaterole,
'canonical_reads_ready' => false, 'canonical_writes_ready' => false,
'blockers' => ['Full canonical response mapping and authorization parity not certified',
'Legacy intake does not capture a sector', 'Runtime privilege separation requires verification with deployment credentials']];
});
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
// This release has no certified canonical adapter: strict gate checks cannot succeed yet.
return $this->option('fail-on-difference') ? self::FAILURE : self::SUCCESS;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Models\InvestmentLegalSource;
use App\Models\User;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\IsolatedQaDatabase;
use Illuminate\Console\Command;
/** Explicit registration: never run during migration, deployment or document import. */
class RegisterFiscalQaDraft extends Command
{
protected $signature = 'fiscal:qa-register-iva {actor : Existing QA editor ID} {source : Existing accepted legal source UUID}';
protected $description = 'Cadastrar apenas o rascunho IVA do primeiro lote numa base QA isolada, sem publicação';
public function handle(SimulationTaxRuleGovernance $governance): int
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 403, 'O cadastro deste lote só é permitido na base QA isolada.');
$actor = User::findOrFail($this->argument('actor'));
$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(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']);
app(SimulationTaxParameterResolver::class)->validateParameters($payload['parameters']);
$existing = $governance->listing($actor, ['rule_code' => $payload['rule_code']])->getCollection()
->firstWhere('rule_version', 1);
if ($existing) {
foreach ($payload as $field => $value) {
$stored = $existing->attributesToArray()[$field] ?? null;
abort_unless($stored == $value, 409, 'O cadastro existente difere desta ficha; não será sobrescrito.');
}
abort_unless($existing->status === 'draft', 409, 'A versão existente já não é um rascunho.');
$this->line(json_encode(['id' => $existing->id, 'status' => 'draft', 'reused' => true], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
$rule = $governance->create($actor, $payload);
$this->line(json_encode(['id' => $rule->id, 'status' => $rule->status, 'reused' => false], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ScanDocumentForMalware;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ScanPendingDocuments extends Command
{
protected $signature = 'documents:scan-pending {--apply : Dispatch scans to the queue} {--limit=100 : Maximum documents to process in this batch}';
protected $description = 'List or dispatch a controlled batch of document malware scans';
public function handle(): int
{
$limit = max(1, min(1000, (int) $this->option('limit')));
$ids = DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted')
->where(function ($query): void {
$query->whereIn('malware_scan_status', ['pending', 'inconclusive', 'failed'])
->orWhere(fn ($stale) => $stale->where('malware_scan_status', 'scanning')->where('updated_at', '<', now()->subMinutes(15)));
})->orderBy('created_at')->limit($limit)->pluck('id');
$this->info(count($ids).' document(s) eligible for this batch.');
if (! $this->option('apply')) {
$this->comment('Dry run only. Add --apply to dispatch scans.');
return self::SUCCESS;
}
foreach ($ids as $id) {
if (DB::table('documents')->where('id', $id)->where('malware_scan_status', 'scanning')->exists()) {
DB::table('documents')->where('id', $id)->update(['malware_scan_status' => 'pending', 'status' => 'quarantined', 'updated_at' => now()]);
}
ScanDocumentForMalware::dispatch((string) $id);
}
$this->info('Queued '.$ids->count().' scan(s). No editorial publication state was changed.');
return self::SUCCESS;
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Domain\Interests\Enums;
enum InterestStatus: string
{
case Received = 'received';
case Submitted = 'submitted';
case InReview = 'in_review';
case Reviewed = 'reviewed';
case Qualification = 'qualification';
case Qualified = 'qualified';
case Contacted = 'contacted';
case ContactAccepted = 'contact_accepted';
case Referred = 'referred';
case DueDiligence = 'due_diligence';
case Negotiation = 'negotiation';
case Pending = 'pending';
case Refused = 'refused';
case Converted = 'converted';
case Cancelled = 'cancelled';
case Closed = 'closed';
}
@@ -0,0 +1,23 @@
<?php
namespace App\Domain\Opportunities\Enums;
enum OpportunityStatus: string
{
case Draft = 'draft';
case Submitted = 'submitted';
case Triage = 'triage';
case InformationPending = 'information_pending';
case InReview = 'in_review';
case Review = 'review';
case ApprovalL1 = 'approval_l1';
case ApprovalL2 = 'approval_l2';
case Approved = 'approved';
case Published = 'published';
case NeedsInformation = 'needs_information';
case Suspended = 'suspended';
case Rejected = 'rejected';
case Withdrawn = 'withdrawn';
case Cancelled = 'cancelled';
case Archived = 'archived';
}
+7 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\AiRun;
use App\Models\Opportunity;
use App\Services\Ai\AiGateway;
@@ -25,6 +26,7 @@ final class AiController extends Controller
'controls' => DB::table('ai_controls')->orderBy('capability')->get(),
'sources' => DB::table('ai_sources')->orderByDesc('created_at')->limit(50)->get(['id', 'title', 'language', 'status', 'nature', 'valid_until', 'submitted_by']),
'documents' => DB::table('documents')->where('status', 'active')->whereNull('deleted_at')->where('media_type', 'text/plain')
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
->whereIn('data_classification', ['public', 'internal'])->limit(100)->get(['id', 'file_name']),
'runs' => AiRun::orderByDesc('created_at')->limit(20)->get(['id', 'capability', 'mode', 'review_status', 'review_requested', 'created_at']),
'today' => now()->toDateString(),
@@ -118,7 +120,8 @@ final class AiController extends Controller
DB::transaction(function () use ($request, $file, $text, $id, $key): void {
DB::table('documents')->insert(['id' => $id, 'file_name' => basename($file->getClientOriginalName()),
'media_type' => 'text/plain', 'storage_key' => $key, 'sha256' => hash('sha256', $text),
'status' => 'active', 'data_classification' => 'internal']);
'size_bytes' => strlen($text), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'internal']);
$this->audit('documents', $id, 'plain_text_registered', $request->user()->id);
});
} catch (\Throwable $exception) {
@@ -126,6 +129,8 @@ final class AiController extends Controller
throw $exception;
}
ScanDocumentForMalware::dispatch($id)->afterCommit();
return $request->expectsJson() ? response()->json(['document_id' => $id], 201)
: back()->with('success', 'Texto guardado no cadastro documental. Registe a fonte e solicite revisão antes de o pesquisar.');
}
@@ -140,6 +145,7 @@ final class AiController extends Controller
if ($data['status'] === 'approved') {
abort_if((int) $source->submitted_by === $request->user()->id, 403, 'Outra pessoa deve validar a autorização.');
$valid = DB::table('documents')->where('id', $source->document_id)->where('status', 'active')
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
->where('version_no', $source->document_version)->where('sha256', $source->document_checksum)->exists();
abort_unless($valid && $source->valid_until >= now()->toDateString(), 422, 'Fonte alterada, expirada ou sem autorização.');
@@ -20,10 +20,8 @@ class HomeController extends Controller
$locale = $validated['locale'] ?? 'pt';
$data = Cache::remember("portal_home_v2_{$locale}", now()->addMinutes(10), function () {
$sectors = Sector::withCount([
'opportunities as opportunities_count' => fn ($q) => $q->published(),
])->where('is_active', true)->orderBy('sort_order')->get();
$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)
@@ -31,14 +29,14 @@ class HomeController extends Controller
return [
'sectors' => $sectors->map(fn ($s) => [
'name' => $s->name,
'id' => $s->id, 'specializations' => $s->specializations, 'name' => $s->name,
'slug' => $s->slug,
'shortDescription' => $s->short_description,
'opportunitiesCount' => $s->opportunities_count,
'url' => "/sectores/{$s->slug}",
])->values()->all(),
'featuredOpportunities' => $featured->map(fn ($o) => [
'reference' => $o->reference,
'id' => $o->id, 'reference' => $o->reference,
'title' => $o->title,
'sector' => $o->sector?->name,
'province' => $o->province,
@@ -51,7 +49,7 @@ class HomeController extends Controller
])->values()->all(),
'stats' => [
'opportunities' => Opportunity::published()->count(),
'sectors' => Sector::where('is_active', true)->count(),
'sectors' => $sectors->count(),
'provinces' => Opportunity::published()->distinct('province')->count('province'),
],
];
@@ -2,13 +2,16 @@
namespace App\Http\Controllers\Api\V1;
use App\Domain\Interests\Enums\InterestStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreInterestRequestRequest;
use App\Jobs\NotifyInterestReceived;
use App\Models\ExistingBusiness;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
@@ -20,8 +23,9 @@ class InterestRequestController extends Controller
{
public function store(StoreInterestRequestRequest $request, InterestSubmission $submission): JsonResponse
{
$validated = $request->validated();
$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' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate();
try {
@@ -33,21 +37,31 @@ class InterestRequestController extends Controller
throw ValidationException::withMessages(['opportunityReference' => 'A oportunidade seleccionada já não está disponível.']);
}
$business = ! empty($validated['existingBusinessId'])
? ExistingBusiness::published()->whereKey($validated['existingBusinessId'])->first()
: null;
if (! empty($validated['existingBusinessId']) && ! $business) {
throw ValidationException::withMessages(['existingBusinessId' => 'A oportunidade seleccionada já não está disponível.']);
}
$interest = InterestRequest::create([
'opportunity_id' => $opportunity?->id,
'existing_business_id' => $business?->id,
'modality' => $business ? $validated['modality'] : null,
'reference' => $this->generateReference(),
'full_name' => trim($validated['fullName']),
'email' => strtolower(trim($validated['email'])),
'phone' => $validated['phone'] ?? null,
'investor_type' => $validated['investorType'],
'country' => $validated['country'],
'metadata' => ['portal_user_id' => $request->user()->id, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null],
'sector_interest' => $validated['sectorInterest'],
'opportunity_reference' => $validated['opportunityReference'] ?? null,
'investment_range' => $validated['investmentRange'] ?? null,
'message' => trim($validated['message']),
'privacy_consent' => (bool) $validated['privacyConsent'],
'consented_at' => now(),
'status' => 'received',
'status' => InterestStatus::Received->value,
'source_page' => Str::limit($request->headers->get('referer', ''), 255, ''),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
@@ -64,6 +78,19 @@ class InterestRequestController extends Controller
}
}
$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?->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],
]);
\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;
});
@@ -72,12 +99,19 @@ class InterestRequestController extends Controller
'message' => 'Manifestação de interesse registada com sucesso.',
'data' => [
'reference' => $interestRequest->reference,
'status' => 'received',
'status' => $interestRequest->status,
'tracking_url' => '/investidor/manifestacoes',
],
], 201);
} catch (IdempotencyConflict) {
return response()->json(['success' => false, 'message' => 'A chave de envio já foi usada com dados diferentes.'], 409);
} catch (ValidationException $exception) {
throw $exception;
} catch (QueryException $exception) {
if ($exception->getCode() === '23505') {
return response()->json(['success' => false, 'message' => 'Já existe uma manifestação activa para esta oportunidade e modalidade.'], 409);
}
throw $exception;
} catch (\Throwable $exception) {
Log::error('Erro ao registar manifestação de interesse', [
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\InvestmentLegalSource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LegislationSourceController extends Controller
{
public function index(Request $request): JsonResponse
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$today = today()->toDateString();
$query = InvestmentLegalSource::query()
->where('validation_status', 'validated')
->whereDate('valid_from', '<=', $today)
->where(fn ($query) => $query->whereNull('valid_until')->orWhereDate('valid_until', '>=', $today));
if (! empty($filters['q'])) {
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
$query->where(fn ($query) => $query
->whereRaw('title ILIKE ?', [$term])
->orWhereRaw('diploma ILIKE ?', [$term])
->orWhereRaw('article ILIKE ?', [$term])
->orWhereRaw('competent_entity ILIKE ?', [$term]));
}
$page = $query->orderBy('title')->orderBy('id')->paginate($filters['per_page'] ?? 25)->withQueryString();
$data = collect($page->items())->map(fn (InvestmentLegalSource $source): array => [
'id' => $source->id,
'title' => $source->title,
'url' => $source->url,
'diploma' => $source->diploma,
'article' => $source->article,
'competent_entity' => $source->competent_entity,
'validation_status' => $source->validation_status,
'version_no' => (int) $source->version_no,
'valid_from' => $source->valid_from,
'valid_until' => $source->valid_until,
'validator_id' => $source->validator_id === null ? null : (int) $source->validator_id,
'validated_at' => $source->validated_at,
])->values();
$body = [
'data' => $data,
'meta' => [
'current_page' => $page->currentPage(),
'per_page' => $page->perPage(),
'total' => $page->total(),
],
];
$etag = 'W/"legal-sources-'.hash('sha256', json_encode([
'filters' => $filters,
'data' => $data,
'total' => $page->total(),
], JSON_THROW_ON_ERROR)).'"';
return response()->json($body)
->header('ETag', $etag)
->header('Cache-Control', 'public, max-age=300');
}
}
@@ -36,6 +36,7 @@ class OpportunityController extends Controller
$term = $validated['q'];
$query->where(fn ($q) => $q
->whereLike('title', "%{$term}%")
->orWhereLike('description', "%{$term}%")
->orWhereLike('summary', "%{$term}%")
->orWhereLike('reference', "%{$term}%")
->orWhereLike('province', "%{$term}%")
@@ -46,7 +47,7 @@ class OpportunityController extends Controller
if (! empty($validated['sector'])) {
$sector = $validated['sector'];
$query->whereHas('sector', fn ($q) => $q->where('slug', $sector));
\App\Services\SectorOrganization::constrain($query, $sector);
}
if (! empty($validated['province'])) {
@@ -54,7 +55,7 @@ class OpportunityController extends Controller
}
if (! empty($validated['maturity_level'])) {
$query->where('maturity_level', $validated['maturity_level']);
\App\Services\OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']);
}
$results = $query->orderByDesc('is_featured')
@@ -0,0 +1,232 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Services\CountryCatalogue;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ReferenceCatalogController extends Controller
{
private const CATALOGS = ['authorities', 'jurisdictions', 'economic-activities', 'countries'];
public function index(Request $request, string $catalog): JsonResponse
{
$catalog = $this->validatedCatalog($catalog);
if ($catalog === 'countries') {
return $this->countries($request);
}
$rules = [
'q' => ['nullable', 'string', 'max:100'],
'version' => ['nullable', 'integer', 'min:1'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'parent_id' => ['nullable', 'uuid'],
'jurisdiction_id' => ['nullable', 'uuid'],
];
if ($catalog === 'jurisdictions') {
$rules['type'] = ['required', 'in:country,province,district,admin_post,locality'];
$rules['parent_id'][] = 'prohibited_if:type,country';
$rules['parent_id'][] = 'required_if:type,district,admin_post,locality';
}
if ($catalog === 'economic-activities') {
$rules['sector_id'] = ['nullable', 'integer', 'exists:sectors,id'];
$rules['origin'] = ['nullable', 'in:portal,cae'];
}
$data = $request->validate($rules);
$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(\App\Services\EconomicReferenceCatalog::class)->available()) { $versionQuery->whereRaw('false'); }
if (isset($data['version'])) {
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
} else {
$versionQuery->where('status', 'published')
->where(fn (Builder $query) => $query->whereNull('valid_from')->orWhere('valid_from', '<=', today()->toDateString()))
->where(fn (Builder $query) => $query->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()));
}
$version = $versionQuery->orderByDesc('version_no')->first();
if (isset($data['version']) && ! $version) {
abort(404, 'Versão publicada do catálogo não encontrada.');
}
if (! $version) {
return response()->json([
'data' => [],
'links' => ['next' => null, 'previous' => null],
'next_page_url' => null,
'prev_page_url' => null,
'current_page' => 1,
'per_page' => $data['per_page'] ?? 20,
'total' => 0,
'last_page' => 1,
'meta' => ['catalog' => $catalog, 'version' => null, 'locale' => 'pt-MZ', 'status' => 'not_published',
'current_page' => 1, 'per_page' => $data['per_page'] ?? 20, 'total' => 0, 'last_page' => 1],
])->header('Cache-Control', 'public, max-age=60');
}
$query = match ($catalog) {
'authorities' => $this->authorities((string) $version->id, $data),
'jurisdictions' => $this->jurisdictions($data),
'economic-activities' => $this->economicActivities((string) $version->id, $data),
};
$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(\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']);
}
return response()->json([
'data' => $items,
'links' => ['next' => $page->nextPageUrl(), 'previous' => $page->previousPageUrl()],
'next_page_url' => $page->nextPageUrl(),
'prev_page_url' => $page->previousPageUrl(),
'current_page' => $page->currentPage(),
'per_page' => $page->perPage(),
'total' => $page->total(),
'last_page' => $page->lastPage(),
'meta' => ['catalog' => $catalog, 'version' => (int) $version->version_no, 'locale' => $version->locale,
'source_version' => $version->source_version, 'valid_from' => $version->valid_from, 'valid_to' => $version->valid_to,
'current_page' => $page->currentPage(), 'per_page' => $page->perPage(), 'total' => $page->total(), 'last_page' => $page->lastPage()],
])->header('Cache-Control', 'public, max-age=300')
->header('ETag', 'W/"catalog-'.substr(hash('sha256', $catalog.'|'.$version->version_no.'|'.json_encode($data, JSON_THROW_ON_ERROR)), 0, 32).'"');
}
private function countries(Request $request): JsonResponse
{
$data = $request->validate(['q' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1', 'per_page' => 'nullable|integer|min:1|max:100', 'parent_id' => 'prohibited', 'version' => 'prohibited']);
$term = strtolower(Str::ascii(trim($data['q'] ?? '')));
$items = collect(app(CountryCatalogue::class)->all())
->filter(fn (array $country) => $term === '' || str_contains(strtolower(Str::ascii(implode(' ', $country))), $term))
->values()->map(fn (array $country) => ['id' => $country['code'], 'value' => $country['code'], 'code' => $country['code'], 'name' => $country['name'], 'label' => $country['name']]);
$page = (int) ($data['page'] ?? 1);
$perPage = (int) ($data['per_page'] ?? 50);
$result = new LengthAwarePaginator($items->forPage($page, $perPage)->values(), $items->count(), $perPage, $page, ['path' => $request->url(), 'query' => $request->query()]);
return response()->json([...$result->toArray(), 'meta' => ['catalog' => 'countries', 'source' => 'Local ISO 3166 catalogue', 'sha256' => hash_file('sha256', resource_path('data/iso3166.tab'))]])
->header('Cache-Control', 'public, max-age=300');
}
/** @param array<string,mixed> $data */
private function authorities(string $versionId, array $data): Builder
{
$query = DB::table('authorities as a')->join('organizations as o', 'o.id', '=', 'a.organization_id')
->leftJoin('geo_locations as g', 'g.id', '=', 'a.jurisdiction_id')
->where('a.catalog_version_id', $versionId)->where('a.status', 'published')
->where('o.data_classification', 'public')->whereNull('o.deleted_at')
->where('a.valid_from', '<=', today()->toDateString())
->where(fn (Builder $builder) => $builder->whereNull('a.valid_to')->orWhere('a.valid_to', '>', today()->toDateString()))
->select(['a.id', 'a.code', 'a.authority_type', 'a.label_pt_mz as name', 'a.organization_id', 'a.jurisdiction_id',
'g.name as jurisdiction_name', 'a.version_no']);
if (! empty($data['jurisdiction_id'])) {
$query->where('a.jurisdiction_id', $data['jurisdiction_id']);
}
if (! empty($data['q'])) {
$this->search($query, $data['q'], ['a.code', 'a.label_pt_mz', 'o.name']);
}
return $query;
}
/** @param array<string,mixed> $data */
private function jurisdictions(array $data): Builder
{
$query = DB::table('geo_locations')->where('source_system', 'cartografia-workbook')
->where('data_classification', 'public')->whereNull('deleted_at')->where('location_type', $data['type'])
->select(['id', 'code', 'name', 'parent_id', 'location_type', 'source_code', 'version_no']);
$parentType = ['province' => 'country', 'district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']] ?? null;
if ($data['type'] !== 'country' && ! empty($data['parent_id'])) {
$validParent = DB::table('geo_locations')->where('id', $data['parent_id'])->where('location_type', $parentType)
->where('source_system', 'cartografia-workbook')->where('data_classification', 'public')->whereNull('deleted_at')->exists();
if (! $validParent) {
throw ValidationException::withMessages(['parent_id' => 'Seleccione um território pai activo do nível anterior.']);
}
$query->where('parent_id', $data['parent_id']);
} elseif (in_array($data['type'], ['district', 'admin_post', 'locality'], true)) {
throw ValidationException::withMessages(['parent_id' => 'Seleccione primeiro o território pai.']);
}
if (! empty($data['q'])) {
$this->search($query, $data['q'], ['code', 'name', 'source_code']);
}
return $query;
}
/** @param array<string,mixed> $data */
private function economicActivities(string $versionId, array $data): Builder
{
$query = DB::table('economic_activities')->where('catalog_version_id', $versionId)->where('status', 'published')
->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', '!=', \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 = \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']);
}
return $query;
}
/** @param list<literal-string> $columns */
private function search(Builder $query, string $search, array $columns): void
{
$term = '%'.addcslashes($search, '%_\\').'%';
$query->where(function (Builder $nested) use ($columns, $term): void {
foreach ($columns as $index => $column) {
if ($index === 0) {
$nested->whereRaw($column.' ILIKE ?', [$term]);
} else {
$nested->orWhereRaw($column.' ILIKE ?', [$term]);
}
}
});
}
/** @return 'authorities'|'jurisdictions'|'economic-activities'|'countries' */
private function validatedCatalog(string $catalog): string
{
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
return $catalog;
}
/**
* @param 'authorities'|'jurisdictions'|'economic-activities' $catalog
* @param array<string, mixed> $row
* @return array<string,mixed>
*/
private function present(string $catalog, array $row): array
{
return match ($catalog) {
'jurisdictions' => ['id' => $row['id'], 'value' => $row['id'], 'code' => $row['code'], 'name' => $row['name'],
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'type' => $row['location_type'],
'source_code' => $row['source_code'], 'version_no' => $row['version_no']],
'economic-activities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'sector_id' => $row['sector_id'],
'version_no' => $row['version_no']],
'authorities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
'label' => $row['name'], 'locale' => 'pt-MZ', 'authority_type' => $row['authority_type'],
'organization_id' => $row['organization_id'], 'jurisdiction_id' => $row['jurisdiction_id'],
'jurisdiction_name' => $row['jurisdiction_name'], 'version_no' => $row['version_no']],
};
}
}
@@ -10,22 +10,12 @@ class SectorController extends Controller
{
public function index(): JsonResponse
{
$sectors = Sector::withCount([
'opportunities as opportunities_count' => fn ($q) => $q->published(),
])
->where('is_active', true)
->orderBy('sort_order')
->get()
->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,
'url' => "/sectores/{$s->slug}",
]);
$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,
'url' => $s->url, 'specializations' => $s->specializations,
]);
return response()->json(['success' => true, 'data' => $sectors]);
}
@@ -35,6 +25,7 @@ class SectorController extends Controller
$sector = Sector::with(['opportunities' => fn ($q) => $q->published()])
->where('slug', $slug)->firstOrFail();
return response()->json(['success' => true, 'data' => $sector]);
$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]);
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSimulationRequest;
use App\Http\Requests\UpdateSimulationScenarioRequest;
use App\Models\QuestionnaireVersion;
use App\Models\Simulation;
use App\Models\SimulationJob;
use App\Policies\SimulationPolicy;
use App\Services\SimulationExecution;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class SimulationController extends Controller
{
public function questionnaire(string $code): JsonResponse
{
abort_unless(preg_match('/^[a-z0-9][a-z0-9-]{0,79}$/', $code) === 1, 404);
$version = QuestionnaireVersion::query()->where('code', $code)->where('locale', 'pt-MZ')->where('status', 'published')
->where(fn ($query) => $query->whereNull('valid_from')->orWhereDate('valid_from', '<=', today()))
->where(fn ($query) => $query->whereNull('valid_to')->orWhereDate('valid_to', '>', today()))
->orderByDesc('version_no')->firstOrFail();
$etag = '"questionnaire-'.$version->id.'-v'.$version->version_no.'"';
return response()->json(['data' => $version])->header('ETag', $etag)->header('Cache-Control', 'private, max-age=60');
}
public function store(StoreSimulationRequest $request, SimulationExecution $execution): JsonResponse
{
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
$actorId = $request->user()?->id;
$token = $actorId === null ? $request->header('X-Simulation-Token') : null;
abort_if($actorId === null && (! is_string($token) || strlen($token) < 32 || strlen($token) > 256), 422, 'X-Simulation-Token opaco gerado pelo cliente é obrigatório para execução anónima.');
$validated = $request->validated();
$organizationId = $validated['organization_id'] ?? null;
$execution->authorizeOrganization($actorId, $organizationId);
if (($validated['scenario_kind'] ?? null) === 'fiscal_rule_published') {
$input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared', 'monthly_sales', 'sales_vat_basis']));
$input['scope_declared'] = (bool) $input['scope_declared'];
$result = $execution->createPublishedFiscal($input, $actorId, $organizationId, $token, $key);
} else {
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
'reduction' => (string) ($validated['reduction'] ?? '0')];
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
}
$simulation = $result['simulation'];
$body = ['data' => $simulation, 'replayed' => $result['replayed']];
if ($actorId === null) {
$body['access_token'] = $token;
}
return response()->json($body, $result['replayed'] ? 200 : 202)
->header('ETag', $execution->etag($simulation))
->header('Location', url('/api/v1/simulations/'.$simulation->id));
}
public function show(Request $request, string $id, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$simulation = $execution->find($id, $request);
if ($request->user()) {
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
} else {
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
}
return response()->json(['data' => $simulation])->header('ETag', $execution->etag($simulation));
}
public function history(Request $request, SimulationExecution $execution): JsonResponse
{
$user = $request->user();
abort_unless($user !== null, 401);
abort_unless($user->hasVerifiedEmail(), 403, 'Verifique o correio electrónico para consultar o histórico.');
$perPage = min(50, max(1, $request->integer('per_page', 20)));
$page = max(1, $request->integer('page', 1));
$history = DB::transaction(function () use ($execution, $user, $perPage, $page) {
$execution->setContext($user->id, null);
return Simulation::query()
->select([
'simulations.id', 'simulations.organization_id', 'visible_organization.name as organization_name',
'simulations.status', 'simulations.version_no', 'simulations.created_at', 'simulations.updated_at',
])
->leftJoin('organizations as visible_organization', 'visible_organization.id', '=', 'simulations.organization_id')
->where(function ($scope) use ($user) {
$scope->where(fn ($personal) => $personal->whereNull('simulations.organization_id')->where('simulations.owner_user_id', $user->id))
->orWhere(fn ($organization) => $organization->whereNotNull('simulations.organization_id')->whereExists(
fn ($membership) => $membership->selectRaw('1')->from('memberships')
->whereColumn('memberships.organization_id', 'simulations.organization_id')
->where('memberships.user_id', $user->id)->whereNull('memberships.deleted_at'),
));
})
->withExists(['reports as has_ready_report' => fn ($query) => $query->where('status', 'ready')])
->orderByDesc('simulations.created_at')->orderByDesc('simulations.id')
->paginate($perPage, ['*'], 'page', $page);
});
return response()->json([
'data' => $history->getCollection()->map(fn (Simulation $simulation): array => [
'id' => $simulation->id,
'scope' => $simulation->organization_id === null ? 'personal' : 'organization',
'organization_name' => $simulation->getAttribute('organization_name'),
'status' => $simulation->status,
'version_no' => $simulation->version_no,
'created_at' => $simulation->created_at?->toISOString(),
'updated_at' => $simulation->updated_at?->toISOString(),
'has_ready_report' => (bool) $simulation->getAttribute('has_ready_report'),
]),
'meta' => [
'current_page' => $history->currentPage(),
'last_page' => $history->lastPage(),
'per_page' => $history->perPage(),
'total' => $history->total(),
],
])->header('Cache-Control', 'no-store, private');
}
public function updateScenario(UpdateSimulationScenarioRequest $request, string $id, string $scenarioId, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id) && Str::isUuid($scenarioId), 404);
$simulation = $execution->find($id, $request);
if ($request->user()) {
abort_unless(app(SimulationPolicy::class)->update($request->user(), $simulation), 403);
} else {
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
}
$validated = $request->validated();
$data = ['label' => (string) $validated['label'], 'base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
'reduction' => (string) ($validated['reduction'] ?? '0')];
$updated = $execution->updateScenario($id, $scenarioId, $data, $request);
return response()->json(['data' => $updated], 202)->header('ETag', $execution->etag($updated));
}
public function report(Request $request, string $id, string $reportId, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id) && Str::isUuid($reportId), 404);
$simulation = $execution->find($id, $request);
if ($request->user()) {
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
} else {
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
}
$report = $simulation->reports->firstWhere('id', $reportId);
abort_if($report === null, 404);
return response()->json(['data' => $report]);
}
public function jobs(Request $request, string $id, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$simulation = $execution->find($id, $request);
if ($request->user()) {
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
} else {
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
}
$jobs = DB::transaction(function () use ($request, $id, $execution) {
$execution->setContext($request->user()?->id, $execution->tokenHash($request));
return SimulationJob::query()->where('simulation_id', $id)
->orderBy('created_at')->orderBy('id')->get();
});
return response()->json(['data' => $jobs]);
}
}
@@ -0,0 +1,188 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\AttachSimulationToDraftRequest;
use App\Http\Requests\ClaimSimulationFormDraftRequest;
use App\Http\Requests\LinkSimulationDraftBusinessRequest;
use App\Http\Requests\StoreSimulationFormDraftRequest;
use App\Policies\SimulationPolicy;
use App\Services\SimulationExecution;
use App\Services\SimulationFormDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class SimulationFormDraftController extends Controller
{
public function store(StoreSimulationFormDraftRequest $request, SimulationFormDrafts $drafts): JsonResponse
{
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
$result = $drafts->create($request->snapshots(), $request, $key);
$draft = $result['draft'];
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']], 'expires_at' => $draft->expires_at?->toISOString()], $result['replayed'] ? 200 : 201)
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function index(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless($request->user() !== null, 401);
return response()->json(['data' => $drafts->ownedDrafts($request->user())])->header('Cache-Control', 'no-store, private');
}
public function claimPreview(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless($request->user() !== null, 401);
return response()->json(['data' => $drafts->claimPreview($request)])->header('Cache-Control', 'no-store, private');
}
public function claim(ClaimSimulationFormDraftRequest $request, SimulationFormDrafts $drafts): JsonResponse
{
$result = $drafts->claim($request, $request->validated('choice'), $request->validated('existing_draft_id'));
$draft = $result['draft'];
return response()->json(['data' => $drafts->present($draft, true), 'choice' => $result['choice'], 'replayed' => $result['replayed'] ?? false, 'expires_at' => $draft->expires_at?->toISOString()])
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function linkableBusinesses(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless($request->user()?->hasVerifiedEmail(), 403);
return response()->json(['data' => $drafts->linkableBusinesses($request->user())])->header('Cache-Control', 'no-store, private');
}
public function businessLink(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
return response()->json(['data' => $drafts->activeBusinessLink($request->user(), $id)])->header('Cache-Control', 'no-store, private');
}
public function linkBusiness(LinkSimulationDraftBusinessRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$result = $drafts->linkBusiness($request->user(), $id, $request->validated('business_id'));
return response()->json(['data' => $result], isset($result['replayed']) ? 200 : 201)->header('Cache-Control', 'no-store, private');
}
public function unlinkBusiness(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
return response()->json(['data' => $drafts->unlinkBusiness($request->user(), $id)])->header('Cache-Control', 'no-store, private');
}
public function current(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
$draft = $drafts->current($request);
if (! $draft) {
return response()->json(['data' => null], 200)->header('Cache-Control', 'no-store, private');
}
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function forgetCurrent(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
$drafts->forgetCurrent($request);
return response()->json([], 204)->header('Cache-Control', 'no-store, private');
}
public function show(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$draft = $drafts->find($id, $request);
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function update(StoreSimulationFormDraftRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$draft = $drafts->update($id, $request->snapshots(), $request);
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function renew(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$draft = $drafts->renew($id, $request);
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function attachSimulation(AttachSimulationToDraftRequest $request, string $id, SimulationFormDrafts $drafts, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$validated = $request->validated();
$simulation = $execution->find($validated['simulation_id'], $request);
$tokenOwned = $simulation->anonymous_token_hash !== null
&& hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request));
if ($request->user()) {
$policy = app(SimulationPolicy::class);
$canTransfer = $simulation->organization_id === null
? $policy->view($request->user(), $simulation)
: $policy->update($request->user(), $simulation);
abort_unless($canTransfer || $tokenOwned, 404);
} else {
abort_unless($tokenOwned, 404);
}
abort_unless($simulation->status === 'completed' && is_array($simulation->result_snapshot), 409, 'Só é possível reutilizar uma simulação concluída.');
$report = $simulation->reports->where('status', 'ready')->sortByDesc('created_at')->first();
abort_unless($report !== null && is_array($report->report_snapshot), 409, 'O relatório versionado da simulação ainda não está disponível.');
abort_unless(preg_match('/^[a-f0-9]{64}$/D', $report->report_sha256) === 1, 409,
'O relatório da simulação não tem um hash verificável.');
$canonicalSnapshots = DB::selectOne('select CAST(? AS jsonb)::text as report_canonical, CAST(? AS jsonb)::text as result_canonical', [
json_encode($report->report_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
json_encode($simulation->result_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
]);
abort_unless(hash_equals($report->report_sha256, hash('sha256', $canonicalSnapshots->report_canonical))
&& hash_equals($canonicalSnapshots->report_canonical, $canonicalSnapshots->result_canonical), 409,
'O relatório não corresponde ao resultado versionado da simulação.');
$inputSnapshot = array_intersect_key($simulation->input_snapshot, array_flip(['base', 'rate', 'reduction']));
$sourceSnapshot = [
'simulation_id' => $simulation->id,
'source_version_no' => $simulation->version_no,
'source_created_at' => $simulation->created_at?->toISOString(),
'source_updated_at' => $simulation->updated_at?->toISOString(),
'input_snapshot' => $inputSnapshot,
'calculation_snapshot' => $simulation->calculation_snapshot,
'result_snapshot' => $simulation->result_snapshot,
'result_sha256' => $report->report_sha256,
'report_id' => $report->id,
'report_version_no' => $report->version_no,
'report_sha256' => $report->report_sha256,
'report_snapshot' => $report->report_snapshot,
'consented_at' => now()->toISOString(),
];
$attached = $drafts->attachSimulation($id, $sourceSnapshot, $request);
$draft = $attached['draft'];
return response()->json([
'data' => $drafts->present($draft, $request->user() !== null),
'replayed' => $attached['replayed'],
'expires_at' => $draft->expires_at?->toISOString(),
])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function destroy(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$drafts->delete($id, $request);
return response()->json([], 204)->header('Cache-Control', 'no-store, private');
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\ResolveSimulationTaxParametersRequest;
use App\Services\SimulationTaxParameterResolver;
use Illuminate\Http\JsonResponse;
class SimulationTaxParameterController extends Controller
{
public function resolve(ResolveSimulationTaxParametersRequest $request, SimulationTaxParameterResolver $resolver): JsonResponse
{
$input = $request->validated();
$data = $resolver->resolvePublishedForPeriod(
$input['component'],
$input['period_start'],
$input['period_end'],
$input['context'] ?? [],
$input['component'] === 'IVA',
);
return response()->json([
'component' => $input['component'],
'period' => ['start' => $input['period_start'], 'end' => $input['period_end']],
'assumptions' => $input['context'] ?? [],
'parameters' => $input['component'] === 'IVA' ? $data['parameters'] : $data,
'diagnostics' => $input['component'] === 'IVA' ? $data['diagnostics'] : [],
])->header('Cache-Control', 'no-store');
}
}
@@ -0,0 +1,73 @@
<?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\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
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)
{
$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)
{
$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)
{
$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)
{
$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];
}));
}
}
+7 -2
View File
@@ -9,6 +9,8 @@ use App\Models\Investor;
use App\Models\Lead;
use App\Models\User;
use App\Services\ConvertInterestToLead;
use App\Services\CountryCatalogue;
use App\Services\DeclaredOriginStatistics;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
@@ -77,7 +79,8 @@ class CrmController extends Controller
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['new', 'qualified', 'disqualified', 'converted'])],
'owner_user_id' => ['nullable', 'integer', 'exists:users,id']]);
'owner_user_id' => ['nullable', 'integer', 'exists:users,id'],
'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]);
$query = Lead::with(['investor:id,legal_name', 'owner:id,name'])
->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())]);
if ($term = $filters['q'] ?? null) {
@@ -92,7 +95,9 @@ class CrmController extends Controller
}
return Inertia::render('crm/leads', ['leads' => $query->latest()->orderBy('id')->paginate(20)->withQueryString(),
'filters' => $filters, 'owners' => $this->owners()]);
'filters' => $filters, 'owners' => $this->owners(),
'countries' => app(CountryCatalogue::class)->all(),
'originStatistics' => app(DeclaredOriginStatistics::class)->forManager($request->user(), $filters['origin'] ?? null)]);
}
public function prepare(InterestRequest $interest): Response|RedirectResponse
+20 -3
View File
@@ -5,7 +5,10 @@ namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestorPath;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
@@ -14,18 +17,32 @@ class DashboardController extends Controller
public function __invoke(Request $request): Response
{
if (! $request->user()->can('manage-portal')) {
return Inertia::render('dashboard', ['summary' => null]);
$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, '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))),
'documents' => count($current['documents']), 'results' => count($current['results']), 'tasks' => count($current['tasks'])] : null,
] : null]);
}
$counts = InterestRequest::query()->selectRaw('status, count(*) as total')
->groupBy('status')->pluck('total', 'status');
$drafts = Opportunity::where('status', 'draft');
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
}
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)]),
'publishedOpportunities' => Opportunity::published()->count(),
'draftOpportunities' => Opportunity::where('status', 'draft')->count(),
'activeSectors' => Sector::where('is_active', true)->count(),
'draftOpportunities' => $drafts->count(),
'activeSectors' => Sector::catalog()->count(),
'recentRequests' => InterestRequest::orderByDesc('id')->limit(5)
->get(['id', 'reference', 'sector_interest', 'status', 'created_at']),
]]);
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
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
{
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.');
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('success', 'Nova análise colocada na fila. O ficheiro permanece privado até haver resultado.');
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Services\EconomicReferenceInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EconomicReferenceController extends Controller
{
public function preview(Request $request, EconomicReferenceInstallation $service): JsonResponse|Response
{
$plan = $service->preview($request->user());
return $request->expectsJson() ? response()->json(['plan' => $plan]) : Inertia::render('management/economic-references', ['plan' => $plan]);
}
public function apply(Request $request, string $operation, EconomicReferenceInstallation $service): JsonResponse
{
return response()->json(['receipt' => $service->apply($request->user(), $operation, $request->all())]);
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
class EditorialArticleController extends Controller
{
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) \Illuminate\Support\Str::uuid(), 'receipt' => null])->header('Cache-Control', 'private, no-store');
}
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) \Illuminate\Support\Str::uuid(), 'receipt' => $receipt])->header('Cache-Control', 'private, no-store');
}
public function showMfa(Request $request)
{
return $this->confirmMfa($request);
}
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(\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.');
$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(\Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider::class)->verify(
\Laravel\Fortify\Fortify::currentEncrypter()->decrypt($actor->two_factor_secret), $data['code']
));
} catch (\Illuminate\Contracts\Encryption\DecryptException $e) {
$valid = false;
}
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 \Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided($actor));
$request->session()->regenerate();
return redirect()->route('editorial.installation')->header('Cache-Control', 'private, no-store');
}
private function installed(): void
{
abort_unless(Schema::hasColumn('portal_resources', 'editorial_content'), 503, 'Editor editorial ainda não instalado.');
}
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')
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256');
}
public function edit(int $id)
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->find($id);
abort_unless($r, 404);
$reading = app(\App\Services\EditorialReading::class);
$original = $reading->original($r);
$content = $reading->content($r, $original);
return Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published],
'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())->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, int $id)
{
$this->installed();
$data = $this->validateContent($request);
DB::transaction(function () use ($request, $id, $data) {
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
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.');
}
$body = trim($data['body']);
if ($body === '') {
throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']);
}
$content = $this->edition($r, $data);
$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')->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])]);
});
return redirect('/management/portal-services/resources/'.$id.'/article')->with('success', 'Artigo guardado como rascunho. Publique expressamente no acervo quando estiver pronto.');
}
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']]);
}
private function edition(object $resource, array $data): array
{
$reading = app(\App\Services\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)
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 404);
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource, null, true)])
->toResponse($request)->header('Cache-Control', 'private, no-store')->header('X-Robots-Tag', 'noindex, nofollow');
}
public function previewContent(Request $request, int $id)
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
$resource = DB::table('portal_resources')->find($id); abort_unless($resource, 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(\App\Services\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)
{
$this->installed();
$resource = app(\App\Services\EditorialReading::class)->publicResources()->where('r.id', $id)->first(['r.*']);
abort_unless($resource, 404);
return Inertia::render('portal/article', ['article' => app(\App\Services\EditorialReading::class)->reading($resource)])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
}
+28 -7
View File
@@ -12,6 +12,7 @@ use App\Models\Skill;
use App\Models\TrainingCapacity;
use App\Models\TrainingProvider;
use App\Services\EducationGapCalculator;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -28,17 +29,22 @@ class EducationController extends Controller
'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 = [];
foreach ([$demands, $capacities] as $query) {
if ($term = trim($filters['q'] ?? '')) {
$query->whereLike('qualification', '%'.addcslashes($term, '%_\\').'%');
}
if ($territory = $filters['geo_location_id'] ?? null) {
$query->where('geo_location_id', $territory);
$path = app(InvestmentGeography::class)->selection($filters);
$territory = $filters['geo_location_id'] ?? ($path !== [] ? $path[array_key_last($path)]->id : null);
if ($territory) {
$territoryIds = app(InvestmentGeography::class)->descendants($territory, includeNonPublic: true);
$query->whereIn('geo_location_id', $territoryIds);
}
if (($filters['status'] ?? null) === 'expired') {
$query->where('valid_until', '<', now()->toDateString());
@@ -47,6 +53,17 @@ class EducationController extends Controller
}
}
$territorialCapacities = clone $capacities;
$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 Inertia::render('education/index', [
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
@@ -59,10 +76,12 @@ class EducationController extends Controller
],
'skills' => Skill::orderBy('name')->get(['id', 'code', 'name']),
'occupations' => Occupation::orderBy('name')->get(['id', 'code', 'name']),
'providers' => TrainingProvider::where('status', '!=', 'inactive')->orderBy('name')->get(['id', 'name']),
'courses' => Course::where('status', '!=', 'retired')->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'))->orderBy('title')->get(['id', 'title', 'provider_id']),
'providers' => $providers->orderBy('name')->get(['id', 'name']),
'courses' => $courses->orderBy('title')->get(['id', 'title', 'provider_id']),
'projects' => Project::orderBy('name')->get(['id', 'code', 'name']),
'territories' => DB::table('geo_locations')->whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'code']),
'territories' => DB::table('geo_locations')->whereNull('deleted_at')
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']),
'today' => now()->toDateString(),
]);
}
@@ -96,7 +115,8 @@ class EducationController extends Controller
$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')],
'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')
@@ -147,7 +167,8 @@ class EducationController extends Controller
return Inertia::render('education/analysis', ['demand' => $demand, 'candidates' => $candidates,
'proposal' => EducationInvestmentProposal::where('labour_demand_id', $id)->first(),
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'territoryPath' => app(InvestmentGeography::class)->path($demand->getAttribute('geo_location_id'), true),
'eligible' => $eligible, 'gap' => $calculator->observed((int) $demand->getAttribute('quantity'), null, $eligible, $current)]);
}
}
@@ -5,12 +5,14 @@ namespace App\Http\Controllers;
use App\Models\EducationInvestmentProposal;
use App\Models\LabourDemand;
use App\Models\Opportunity;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use LogicException;
class EducationInvestmentController extends Controller
{
@@ -27,6 +29,10 @@ class EducationInvestmentController extends Controller
'risks' => ['required', 'string', 'min:10', 'max:5000'],
'source' => ['required', 'string', 'min:10', 'max:5000'],
'currency' => ['required', Rule::in(['MZN', 'USD', 'EUR', 'ZAR'])],
'province_id' => ['required', 'uuid'],
'district_id' => ['nullable', 'uuid'],
'admin_post_id' => ['nullable', 'uuid'],
'locality_id' => ['nullable', 'uuid'],
]);
DB::transaction(function () use ($request, $id, $data): void {
@@ -38,6 +44,14 @@ class EducationInvestmentController extends Controller
if (EducationInvestmentProposal::withTrashed()->where('labour_demand_id', $id)->exists()) {
return;
}
$territory = app(InvestmentGeography::class)->selection($data, true);
$lastTerritoryKey = array_key_last($territory);
if ($lastTerritoryKey === null) {
throw new LogicException('A proposta educativa exige uma localização territorial.');
}
$province = $territory[0];
$district = $territory[1] ?? null;
$primaryTerritory = $territory[$lastTerritoryKey];
$reference = 'EDU-'.Str::uuid();
$opportunity = new Opportunity;
PortalAudit::save($opportunity, [
@@ -45,6 +59,8 @@ class EducationInvestmentController extends Controller
'slug' => strtolower($reference), 'title' => $data['title'],
'summary' => 'Proposta educativa em preparação. Lacuna e viabilidade pendentes de validação institucional.',
'description' => $data['intervention'], 'estimated_investment_currency' => $data['currency'],
'province' => $province->name, 'district' => $district?->name,
'primary_territory_id' => $primaryTerritory->id, 'territory_version' => 1,
'status' => 'draft', 'is_featured' => false,
], $request->user()->id);
PortalAudit::save(new EducationInvestmentProposal, [
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Services\ExistingBusinessClassification;
use App\Services\Mne\BusinessRepository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ExistingBusinessClassificationController extends Controller
{
public function show(Request $request, string $id, ExistingBusinessClassification $service): Response|JsonResponse
{
$props = ['supplement' => $service->privateView($request->user(), app(BusinessRepository::class)->find($id))];
return $request->expectsJson() ? response()->json($props) : Inertia::render('investment/business-classification', $props);
}
public function act(Request $request, string $id, ExistingBusinessClassification $service): JsonResponse
{
$business = $service->act($request->user(), $id, $request->all());
return response()->json(['supplement' => $service->privateView($request->user(), $business)]);
}
}
@@ -4,15 +4,24 @@ namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Models\Sector;
use App\Policies\ExistingBusinessPolicy;
use App\Services\BusinessFormSchema;
use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake;
use App\Services\ExistingBusinessObjects;
use App\Services\ExistingBusinessVerification;
use App\Services\InvestmentGeography;
use App\Services\Mne\BusinessRepository;
use App\Services\Mne\CompatibilityShadow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -21,14 +30,33 @@ class ExistingBusinessController extends Controller
public function index(Request $request): Response|JsonResponse
{
$management = $request->is('management/*', 'api/v1/existing-business/review*');
$query = ExistingBusiness::query();
$query = app(BusinessRepository::class)->query();
if ($management) {
Gate::authorize('reviewQueue', ExistingBusiness::class);
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', ['submitted', 'in_review', 'needs_information']);
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', [
'submitted', 'triage', 'in_review', 'needs_information', 'approval_l1', 'approval_l2', 'approved',
]);
app(ExistingBusinessPolicy::class)->scopeReviewQueue($query, $request->user());
} else {
$query->where('owner_user_id', $request->user()->id);
}
$props = ['records' => $query->orderByDesc('updated_at')->paginate(20)->through(fn ($row) => $this->record($row)),
$filters = $request->validate(['country' => ['nullable', app(CountryCatalogue::class)->rule()], 'origin' => ['nullable', Rule::in([...array_column(app(CountryCatalogue::class)->all(), 'code'), 'unknown'])], 'page' => ['nullable', 'integer', 'min:1']]);
$query->orderByDesc('updated_at');
if (! empty($filters['country']) || ! empty($filters['origin'])) {
// The private payload is encrypted: filter only the already-authorized collection.
$matches = $query->get()->filter(function ($business) use ($filters) {
$payload = $business->payload;
$origin = $payload['declared_origin_country_code'] ?? null;
$validOrigin = in_array($origin, array_column(app(CountryCatalogue::class)->all(), 'code'), true) ? $origin : 'unknown';
return (empty($filters['country']) || ($payload['country'] ?? null) === $filters['country']) && (empty($filters['origin']) || $validOrigin === $filters['origin']);
})->values();
$page = (int) ($filters['page'] ?? 1);
$records = new LengthAwarePaginator($matches->forPage($page, 20)->values(), $matches->count(), 20, $page, ['path' => $request->url(), 'query' => $request->query()]);
} else {
$records = $query->paginate(20)->withQueryString();
}
$props = ['records' => $records->through(fn ($row) => $this->record($row)), 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all(),
'management' => $management, 'modalities' => ExistingBusinessDrafts::MODALITIES];
return $request->expectsJson() ? response()->json($props) : Inertia::render('investment/businesses', $props);
@@ -36,10 +64,15 @@ class ExistingBusinessController extends Controller
public function show(Request $request, string $id): Response|JsonResponse
{
$business = ExistingBusiness::findOrFail($id);
$business = app(BusinessRepository::class)->find($id);
Gate::authorize('view', $business);
$props = ['record' => $this->record($business), 'canEdit' => $request->user()->can('update', $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' => \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),
'canApproveLevelTwo' => $request->user()->can('approveLevelTwo', $business),
'canPublish' => $request->user()->can('publish', $business),
'modalities' => ExistingBusinessDrafts::MODALITIES,
'objectKinds' => ExistingBusinessObjects::KINDS,
'verificationChecks' => ExistingBusinessVerification::CHECKS,
@@ -63,8 +96,8 @@ class ExistingBusinessController extends Controller
public function transition(Request $request, ExistingBusinessIntake $intake, string $id): JsonResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'action' => ['required', 'string'],
'note' => ['required', 'string'], 'review_consent' => ['nullable', 'boolean']]);
$business = $intake->transition($request->user(), $id, $data['version_no'], $data['action'], $data['note'], (bool) ($data['review_consent'] ?? false));
'note' => ['required', 'string'], 'review_consent' => ['nullable', 'boolean'], 'confirmation_code' => ['nullable', 'string', 'max:100']]);
$business = $intake->transition($request->user(), $id, $data['version_no'], $data['action'], $data['note'], (bool) ($data['review_consent'] ?? false), $data['confirmation_code'] ?? null);
return response()->json($this->record($business));
}
@@ -79,12 +112,17 @@ class ExistingBusinessController extends Controller
$saved = $objects->save($request->user(), $id, $request->all(), $object);
return response()->json(['id' => $saved->id, 'verification_status' => 'pending',
'version_no' => ExistingBusiness::findOrFail($id)->version_no], $object ? 200 : 201);
'version_no' => app(BusinessRepository::class)->find($id)->version_no], $object ? 200 : 201);
}
/** @return array<string,mixed> */
private function record(ExistingBusiness $business): array
{
return [...$business->only(['id', 'code', 'modality', 'status', 'classification', 'version_no']), 'payload' => $business->payload];
return [...$business->only([
'id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id', 'public_title', 'public_slug',
'activity_code', 'stage', 'objective', 'currency', 'amount_min', 'amount_max', 'available_percentage',
'funds_use', 'submitted_at', 'published_at', 'expires_at', 'next_action', 'next_action_due_at', 'assigned_to',
]), 'payload' => $business->payload,
'territory_paths' => app(InvestmentGeography::class)->displayPaths($business->payload)];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentComplements;
use Illuminate\Http\Request;
final class InvestmentComplementController extends Controller
{
public function index(Request $request, InvestmentComplements $service)
{
$data = $request->validate(['origin' => [\Illuminate\Validation\Rule::requiredIf(!$request->has('contexts')), 'nullable', 'string', 'max:100'], 'territory_id' => 'nullable|uuid',
'contexts' => 'sometimes|array|max:100', 'contexts.*' => 'array:origin,territory_id', 'contexts.*.origin' => 'required|string|max:100', 'contexts.*.territory_id' => 'nullable|uuid']);
$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 (\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException $e) {
$status = $e instanceof \Symfony\Component\HttpKernel\Exception\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');
}
}
@@ -10,6 +10,7 @@ use App\Services\InvestmentZones;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
@@ -24,8 +25,10 @@ class InvestmentDiscoveryController extends Controller
$filters['education'] = true;
}
return Inertia::render('investment/catalogue', ['filters' => $filters, 'references' => $catalogue->references(),
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters) : [],
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'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)]);
@@ -41,11 +44,7 @@ class InvestmentDiscoveryController extends Controller
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->where(function ($query) use ($ids) {
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) {
$query->orWhereIn($foreign, DB::table($table)->whereNull('deleted_at')->whereIn('primary_territory_id', $ids)->select('id'));
}
});
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $ids);
}
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
return $query->whereRaw('false')->get();
@@ -53,13 +52,16 @@ class InvestmentDiscoveryController extends Controller
if (in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
$query->whereNotNull($filters['dimension'].'_id');
}
foreach (['zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'geo_location_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level'] as $field) {
if (! empty($filters['geo_location_id'])) {
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, app(InvestmentGeography::class)->descendants($filters['geo_location_id']));
}
foreach (['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation'] as $field) {
if (isset($filters[$field]) && $filters[$field] !== '' && $filters[$field] !== false && $filters[$field] !== []) {
return $query->whereRaw('false')->get();
}
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
$query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%']));
}
foreach (['cluster_id', 'corridor_id', 'project_id'] as $field) {
if (! empty($filters[$field])) {
@@ -75,16 +77,26 @@ class InvestmentDiscoveryController extends Controller
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString());
}
public function references(InvestmentCatalogue $catalogue): JsonResponse
public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->references());
return response()->json($catalogue->references(false, $catalogue->filters($request)));
}
public function detail(Request $request, string $slug, InvestmentCatalogue $catalogue): Response
{
$record = Opportunity::published()->with('sector:id,name,slug')->where('slug', $slug)->firstOrFail();
$data = $request->validate(['zone_id' => ['nullable', 'uuid']]);
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'from_dossier' => ['nullable', 'uuid']]);
$dossierOrigin = isset($data['from_dossier']) ? InvestmentPortfolioController::visible()->whereKey($data['from_dossier'])->firstOrFail() : null;
if ($dossierOrigin) {
$linked = in_array($record->id, app(\App\Services\CorridorAgribusinessOpportunities::class)->portfolioOpportunityIds($dossierOrigin), true);
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $relation => $foreign) {
if ($dossierOrigin->$foreign && DB::table('opportunity_'.$relation)->where('opportunity_id', $record->id)->where($foreign, $dossierOrigin->$foreign)->exists()) {
$linked = true;
}
}
abort_unless($linked, 404);
}
$origin = null;
if (! empty($data['zone_id'])) {
$origin = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
@@ -92,7 +104,13 @@ class InvestmentDiscoveryController extends Controller
abort_unless($origin !== null, 404);
}
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin]);
$returnFilters = $request->query();
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
return Inertia::render('investment/detail', ['complements' => app(\App\Services\InvestmentComplements::class)->options('opportunity:'.$record->id), 'record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
}
public function detailApi(string $slug, InvestmentCatalogue $catalogue): JsonResponse
@@ -114,7 +132,24 @@ class InvestmentDiscoveryController extends Controller
public function map(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->map($catalogue->filters($request)));
$filters = $catalogue->filters($request);
$context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]);
$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 !== []);
if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) {
$zoneFilters = $filters;
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
$zoneFilters['category'] = $filters['dimension'];
}
$zones = app(InvestmentZones::class)->search($zoneFilters);
foreach (['zee_id', 'zfi_id', 'park_id'] as $key) {
if (! empty($filters[$key])) {
$zones->where('id', $filters[$key]);
}
}
$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(), 'context_limit_per_collection' => 30]);
}
public function compare(Request $request, InvestmentCatalogue $catalogue): JsonResponse
@@ -27,7 +27,8 @@ class InvestmentLegalSourceController extends Controller
$source->refresh();
$this->audit($source, $request->user()->id, 'created');
return response()->json($source->fresh(), 201);
return response()->json($source->fresh(), 201)
->header('Location', url('/api/v2/investment/admin/legal-sources/'.$source->id));
});
}
@@ -39,9 +40,12 @@ class InvestmentLegalSourceController extends Controller
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return DB::transaction(function () use ($request, $id, $action, $data) {
DB::select('select pg_advisory_xact_lock(261022, hashtext(?))', [$id]);
$source = InvestmentLegalSource::whereKey($id)->lockForUpdate()->firstOrFail();
abort_if((int) $source->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
if ($action === 'validate') {
abort_if(str_contains(mb_strtolower($source->article), 'pending') || str_contains(mb_strtolower($source->article), 'pendente')
|| str_contains(mb_strtolower($source->article), 'a rever'), 422, 'Indique o artigo e confirme o texto oficial antes da validação.');
abort_if((int) $source->owner_id === $request->user()->id, 403, 'Outra pessoa deve validar a fonte.');
abort_unless($source->validation_status === 'pending', 422, 'Crie nova referência para substituir fonte revogada.');
}
@@ -3,10 +3,15 @@
namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Project;
use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\InvestmentZones;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -17,9 +22,15 @@ class InvestmentPortfolioController extends Controller
{
public static function related(InvestmentDossier $first, InvestmentDossier $second): bool
{
foreach ([[$first, $second], [$second, $first]] as [$cluster, $project]) {
if ($cluster->cluster_id && $project->project_id && app(InvestmentRelations::class)->memberships()
->where('cp.cluster_id', $cluster->cluster_id)->where('cp.project_id', $project->project_id)->exists()) {
return true;
}
}
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$first, $second], [$second, $first]] as [$a, $b]) {
if ($a->$left && $b->$right && DB::table($table)->where($left, $a->$left)->where($right, $b->$right)->whereNull('deleted_at')->exists()) {
if ($a->$left && $b->$right && self::currentRelation(DB::table($table)->where($left, $a->$left)->where($right, $b->$right))->exists()) {
return true;
}
}
@@ -28,6 +39,13 @@ class InvestmentPortfolioController extends Controller
return false;
}
private static function currentRelation(QueryBuilder $query): QueryBuilder
{
return $query->whereNull('deleted_at')->where('validation_status', 'validated')
->whereDate('valid_from', '<=', today()->toDateString())
->where(fn ($q) => $q->whereNull('valid_until')->orWhereDate('valid_until', '>=', today()->toDateString()));
}
/** @return Builder<InvestmentDossier> */
public static function visible(): Builder
{
@@ -42,17 +60,30 @@ class InvestmentPortfolioController extends Controller
public function index(Request $request): Response
{
$management = $request->routeIs('management.portfolio*');
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project']]);
$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');
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
$query->where(fn ($q) => $q->where('title', 'ilike', '%'.$filters['q'].'%')->orWhereRaw('content::text ILIKE ?', ['%'.$filters['q'].'%']));
}
$territorySelection = app(InvestmentGeography::class)->selection($filters);
if ($territorySelection !== []) {
$territoryIds = app(InvestmentGeography::class)->descendants($territorySelection[array_key_last($territorySelection)]->id);
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds);
}
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
'management' => $management, 'filters' => $filters]);
'management' => $management, 'filters' => $filters, 'sectors' => $sectorOptions, 'territorySelection' => $territorySelection]);
}
public function show(Request $request, string $id): Response
@@ -63,8 +94,8 @@ class InvestmentPortfolioController extends Controller
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
if ($record->$origin) {
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], DB::table($table)
->where($origin, $record->$origin)->whereNull('deleted_at')->pluck($destination)->all());
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], self::currentRelation(DB::table($table)
->where($origin, $record->$origin))->pluck($destination)->all());
}
}
}
@@ -81,6 +112,7 @@ class InvestmentPortfolioController extends Controller
$opportunities = Opportunity::published()->where(function ($query) use ($record) {
$query->whereRaw('false');
$query->orWhereIn('id', app(\App\Services\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'));
@@ -98,10 +130,27 @@ class InvestmentPortfolioController extends Controller
}
}
return Inertia::render('portfolio/show', ['record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
$anchorProject = $record->project_id
? Project::whereKey($record->project_id)->whereNull('deleted_at')->where('status', '!=', 'draft')->first(['id', 'code', 'name', 'status', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])
: null;
$existingSubmissions = ! $management && $request->user()
? InvestmentSubmission::where('user_id', $request->user()->id)->where('dossier_id', $record->id)->where('status', 'draft')
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
: collect();
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(\App\Services\InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
'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'],
@@ -114,7 +163,7 @@ class InvestmentPortfolioController extends Controller
'publication_status' => 'draft', 'published_at' => null, 'approval_evidence' => null, 'approval_basis' => 'manager'], $request->user()->id);
});
return back()->with('success', 'Rascunho guardado. Pode aprovar e publicar a ficha.');
return back()->with('success', 'Rascunho guardado. A publicacao aguarda um revisor diferente do editor.');
}
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
@@ -123,6 +172,8 @@ class InvestmentPortfolioController extends Controller
DB::transaction(function () use ($request, $dossier, $data) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de publicar.');
abort_unless($dossier->editor_id !== null, 422, 'A ficha deve ser assumida e revista antes da publicacao.');
abort_if((int) $dossier->editor_id === (int) $request->user()->id, 403, 'A publicacao exige um revisor diferente do editor.');
PortalAudit::save($dossier, [...$data, 'version_no' => $dossier->version_no + 1, 'approval_basis' => 'manager', 'reviewer_id' => $request->user()->id, 'publication_status' => 'published', 'published_at' => now()], $request->user()->id);
});
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentRelationshipRepair;
use App\Services\InvestmentTerritorialTraceability;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class InvestmentRelationshipRepairController extends Controller
{
public function preview(InvestmentRelationshipRepair $repair): Response
{
return Inertia::render('management/relationship-repair', [...$repair->preview(), 'traceability' => app(InvestmentTerritorialTraceability::class)->preview()]);
}
public function apply(Request $request, InvestmentRelationshipRepair $repair): RedirectResponse|JsonResponse
{
if ($request->input('operation') === 'traceability') {
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'reverse' => ['required', 'boolean'],
'reason' => ['required_if:reverse,true', 'nullable', 'string', 'max:1000'], 'confirm_reverse' => ['accepted_if:reverse,true']]);
try {
$result = app(InvestmentTerritorialTraceability::class)->apply($data['hash'], $request->user()->id, $data['reverse'], $data['reason'] ?? null);
} catch (HttpException $exception) {
if ($request->expectsJson()) {
throw $exception;
}
return back()->withErrors(['operation' => $exception->getMessage()]);
}
return $request->expectsJson() ? response()->json($result) : back()->with('success', $data['reverse'] ? 'Rastreabilidade revertida, histórico preservado.' : 'Rastreabilidade aplicada; validações e dados territoriais preservados.');
}
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'row_id' => ['nullable', 'string', 'size:64'],
'validation_hash' => ['required_with:row_id', 'nullable', 'string', 'size:64']]);
$result = $repair->apply($data['hash'], $request->user()->id, $data['row_id'] ?? null, $data['validation_hash'] ?? null);
return $request->expectsJson() ? response()->json($result) : back()->with('success', $result['replayed'] ? 'Este plano já foi aplicado; nenhum duplicado criado.' : 'Associações documentadas aplicadas. Registos de oportunidades permanecem preparados sem publicação automática.');
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\ClusterProject;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentGap;
use App\Models\Project;
@@ -21,7 +22,7 @@ class InvestmentStructureController extends Controller
public function index(Request $request, string $structure): Response
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
$model = $this->model($structure);
$query = $model::query();
if ($term = $filters['q'] ?? null) {
$query->where(function ($q) use ($term): void {
@@ -33,9 +34,9 @@ class InvestmentStructureController extends Controller
'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
}
public function edit(string $structure, ?string $id = null): Response
public function edit(Request $request, string $structure, ?string $id = null): Response
{
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : null;
return Inertia::render('structures/editor', ['structure' => $structure, 'record' => $record,
@@ -49,30 +50,36 @@ 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']) : [],
'viewerId' => $request->user()->id,
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at')
->orderBy('projects.name')->get(['cluster_projects.id', 'project_id', 'role', 'projects.name']) : [],
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name']) : [],
]);
}
public function save(Request $request, string $structure, ?string $id = null): RedirectResponse
{
$isProject = $structure === 'projects';
$model = $isProject ? Project::class : EconomicCluster::class;
$isCluster = $structure === 'clusters';
$isCorridor = $structure === 'corridors';
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : new $model;
$rules = [
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
'name' => ['required', 'string', 'max:200'],
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ['planned', 'active', 'inactive'])],
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))],
];
if ($isProject || $isCorridor) {
$rules['description'] = ['nullable', 'string', 'max:10000'];
}
if ($isProject) {
$rules += ['description' => ['nullable', 'string', 'max:10000'],
$rules += [
'budget_amount' => ['nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/'],
'budget_currency' => ['nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/'],
'start_date' => ['nullable', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', ...($request->filled('start_date') ? ['after_or_equal:start_date'] : [])]];
} else {
} elseif ($isCluster) {
$rules['sector'] = ['required', 'string', 'max:200'];
}
if (! $id) {
@@ -82,7 +89,7 @@ class InvestmentStructureController extends Controller
}
$data = $request->validate($rules);
if (! $id && $request->has('province_id')) {
$path = app(InvestmentGeography::class)->selection($data);
$path = app(InvestmentGeography::class)->selection($data, true);
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
@@ -92,14 +99,52 @@ class InvestmentStructureController extends Controller
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
}
/** @return class-string<Project|EconomicCluster|DevelopmentCorridor> */
private function model(string $structure): string
{
return match ($structure) {
'projects' => Project::class,
'clusters' => EconomicCluster::class,
'corridors' => DevelopmentCorridor::class,
default => abort(404),
};
}
public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse
{
$data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]]);
'version_no' => ['required', 'integer', 'min:0'],
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])],
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]);
DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->first() ?? new ClusterProject;
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first();
if ($link) {
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
$changed = false;
foreach (['role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until'] as $field) {
$current = $link->getAttribute($field);
$current = $current instanceof \DateTimeInterface ? $current->format('Y-m-d') : $current;
if (($current ?: null) !== (($data[$field] ?? null) ?: null)) {
$changed = true;
break;
}
}
if (! $changed) {
return;
}
} else {
abort_if((int) $data['version_no'] !== 0, 409, 'A associacao foi alterada. Actualize a pagina.');
$link = new ClusterProject;
}
unset($data['version_no']);
$nextVersion = $link->exists ? ((int) $link->version_no + 1) : 1;
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id, 'validation_status' => 'pending',
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => $nextVersion], $request->user()->id);
});
return back()->with('success', 'Associação ao projecto guardada.');
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class InvestmentStructureRelationController extends Controller
{
private const TABLES = [
'cluster-project' => ['cluster_projects', 'cluster_id', 'project_id', 'economic_clusters', 'projects'],
'corridor-project' => ['corridor_projects', 'corridor_id', 'project_id', 'development_corridors', 'projects'],
'corridor-cluster' => ['corridor_clusters', 'corridor_id', 'cluster_id', 'development_corridors', 'economic_clusters'],
];
public function store(Request $request, string $kind): RedirectResponse
{
[$table, $left, $right, $leftTable, $rightTable] = $this->definition($kind);
$data = $request->validate([
$left => ['required', 'uuid', Rule::exists($leftTable, 'id')->whereNull('deleted_at')],
$right => ['required', 'uuid', Rule::exists($rightTable, 'id')->whereNull('deleted_at')],
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []),
]);
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request): void {
DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail();
$existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first();
abort_if($existing !== null, 409, 'Associacao ja registada.');
$values = [...$data, 'association_type' => $data['association_type'], 'validation_status' => 'pending',
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => 1,
'deleted_at' => null, 'updated_at' => now()];
$id = (string) Str::uuid();
DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']);
$this->audit($request, $table, $id, 'submitted', 1);
});
return back()->with('success', 'Associacao submetida para revisao independente.');
}
public function review(Request $request, string $kind, string $id, string $action): RedirectResponse
{
abort_unless(in_array($action, ['validate', 'revoke'], true), 404);
[$table] = $this->definition($kind);
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
DB::transaction(function () use ($request, $table, $id, $action, $data): void {
$relation = DB::table($table)->where('id', $id)->lockForUpdate()->first();
abort_unless($relation !== null, 404);
abort_if((int) $relation->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
if ($action === 'validate') {
abort_if((int) $relation->editor_id === (int) $request->user()->id, 403, 'A associacao exige revisao independente.');
abort_unless($relation->validation_status === 'pending' && $relation->source_reference && $relation->evidence_reference && $relation->valid_from, 422, 'Fonte, evidencia ou vigencia em falta.');
}
$nextVersion = (int) $relation->version_no + 1;
DB::table($table)->where('id', $id)->update(['validation_status' => $action === 'validate' ? 'validated' : 'revoked',
'validator_id' => $request->user()->id, 'version_no' => $nextVersion, 'updated_at' => now()]);
$this->audit($request, $table, $id, $action, $nextVersion);
});
return back()->with('success', $action === 'validate' ? 'Associacao validada.' : 'Associacao revogada.');
}
/** @return array{string,string,string,string,string} */
private function definition(string $kind): array
{
abort_unless(isset(self::TABLES[$kind]), 404);
return self::TABLES[$kind];
}
private function audit(Request $request, string $table, string $id, string $action, int $version): void
{
DB::table('audit_events')->insert(['source_system' => 'investment-structure-relations', 'entity_type' => $table,
'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action,
'after_data' => json_encode(['version_no' => $version, 'validation_status' => $action === 'validate' ? 'validated' : null], JSON_THROW_ON_ERROR)]);
}
}
@@ -2,17 +2,22 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\InvestmentSpecificNeed;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Services\CountryCatalogue;
use App\Services\InterestSubmission;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentWorkspace;
use App\Services\InvestmentZones;
use App\Services\InvestorContextConsumers;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
@@ -26,7 +31,14 @@ class InvestmentSubmissionController extends Controller
{
public function index(Request $request): Response|JsonResponse
{
$submissions = InvestmentSubmission::where('user_id', $request->user()->id)->latest()->paginate(20)->through(function ($row) {
$query = InvestmentSubmission::where('user_id', $request->user()->id);
if (! $request->user()->hasVerifiedEmail()) {
// The existing payload is encrypted; scope after decryption, never infer ownership from email.
$simpleIds = InvestmentSubmission::where('user_id', $request->user()->id)->where('status', 'submitted')->get(['id', 'payload'])
->filter(fn ($row) => ($row->payload['simple_participation'] ?? false) === true)->modelKeys();
$query->whereIn('id', $simpleIds);
}
$submissions = $query->latest()->paginate(20)->through(function ($row) {
$interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null;
return ['id' => $row->id, 'status' => $row->status, 'version' => $row->version, 'created_at' => $row->created_at,
@@ -48,7 +60,10 @@ class InvestmentSubmissionController extends Controller
$company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $request->user()->id)->first();
abort_unless($company !== null, 404);
$companyData = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'country' => $companyData['country']];
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'incorporation_country_code' => $companyData['incorporation_country_code'] ?? null];
if (isset($companyData['organization_country_snapshot'])) {
$payload['organization_country_snapshot'] = $companyData['organization_country_snapshot'];
}
}
unset($data['company_id']);
if (! empty($data['opportunity_id'])) {
@@ -110,24 +125,58 @@ class InvestmentSubmissionController extends Controller
$target = $row->opportunity_id ? Opportunity::published()->find($row->opportunity_id) : null;
$dossier = $row->dossier_id ? InvestmentPortfolioController::visible()->find($row->dossier_id) : null;
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
$data = ['submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null), 'references' => [
$contextAvailable = app(VirtualDesk::class)->installed();
$boundCase = $contextAvailable ? DB::table('desk_cases')->where('submission_id', $id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
$contextChoices = $contextAvailable && (int) $row->user_id === (int) $request->user()->id && $row->status === 'draft' && ! $boundCase
? DB::table('desk_cases')->where('user_id', $request->user()->id)->whereNotNull('journey_id')->whereNull('closed_at')->whereNull('submission_id')->orderBy('title')->get(['id', 'title', 'version']) : [];
$data = ['contextDossierId' => $boundCase?->id, 'contextChoices' => $contextChoices, 'boundContextRevision' => $contextAvailable ? app(InvestorContextConsumers::class)->submissionRevision($id) : 0, 'countries' => app(CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null) ?? ($row->payload['opportunity_title'] ?? null), 'references' => [
...$catalogue->references(), 'options' => DB::table('investment_reference_options')->orderBy('label')->get()],
'needs' => InvestmentSpecificNeed::where('submission_id', $id)->get(),
'attachments' => DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.submission_id', $id)->whereNull('d.deleted_at')->get(['a.id', 'd.file_name']),
'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at']),
'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at'])
->concat($interest ? DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)->orderBy('created_at')->get(['to_status', 'created_at'])->map(fn ($event) => ['action' => $event->to_status, 'version' => $row->version, 'created_at' => $event->created_at]) : [])->sortBy('created_at')->values(),
'revisions' => DB::table('investment_submission_revisions')->where('submission_id', $id)->orderByDesc('version')->get(['version', 'created_at']),
'messages' => DB::table('investment_messages')->where('submission_id', $id)->orderBy('created_at')->get(['id', 'sender_role', 'body', 'created_at'])->map(function ($message) {
$message->body = Crypt::decryptString($message->body);
return $message;
}),
'receipt' => $interest?->only(['reference', 'status']), 'management' => (int) $row->user_id !== $request->user()->id];
'receipt' => $interest?->only(['reference', 'status', 'next_action', 'next_action_due_at']), 'management' => (int) $row->user_id !== $request->user()->id];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/submission', $data);
}
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.');
$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.
$row = $this->owned($request, $id, true, true);
$desk = app(VirtualDesk::class);
$case = $desk->case($request->user(), $data['case_id'], true, true);
abort_unless((int) $case->user_id === (int) $request->user()->id && $case->journey_id, 403, 'Dossier próprio autorizado necessário.');
$existing = DB::table('desk_cases')->where('submission_id', $id)->first(['id']);
if ($existing && $existing->id === $case->id) {
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version, 'replayed' => true];
}
abort_if($existing || $case->submission_id, 409, 'Existe um vínculo anterior; não foi substituído nem criado outro dossier.');
abort_unless($row->status === 'draft' && ! $case->closed_at && (int) $row->version === (int) $data['version'] && (int) $case->version === (int) $data['case_version'], 409, 'Rascunho ou dossier alterado. Recarregue antes de associar.');
DB::table('desk_cases')->where('id', $case->id)->update(['submission_id' => $id, 'version' => $case->version + 1, 'updated_at' => now()]);
$row->update(['version' => $row->version + 1]);
// 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);
}
private function owned(Request $request, string $id, bool $lock = false, bool $ownerOnly = false): InvestmentSubmission
{
$query = InvestmentSubmission::whereKey($id);
@@ -143,10 +192,17 @@ class InvestmentSubmissionController extends Controller
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
'payload' => ['required', 'array:full_name,email,phone,country,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: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.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' => [$required, 'string', 'max:100'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
'payload.organisation' => ['nullable', 'string', 'max:200'], 'payload.representative_role' => ['nullable', 'string', 'max:150'],
'payload.experience' => ['nullable', 'string', 'max:2000'], 'payload.investment_capacity' => ['nullable', 'string', 'max:1000'],
'payload.profile_id' => [$required, 'integer'], 'payload.sector' => [$required, 'string', 'max:150'],
@@ -193,6 +249,9 @@ 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(\App\Services\InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting);
else unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
return $data;
}
@@ -212,7 +271,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(['payload' => $data['payload'], 'version' => $row->version + 1]);
$row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id'=>(int)substr($data['payload']['context_opportunity'],12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -225,6 +284,23 @@ class InvestmentSubmissionController extends Controller
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Rascunho guardado.');
}
/** @param array<string, mixed> $payload
* @return array<string, mixed> */
private function preserveCountrySource(InvestmentSubmission $row, array $payload): array
{
$source = $row->payload['organization_country_snapshot'] ?? null;
if (isset($payload['organization_country_snapshot']) && (! is_array($payload['organization_country_snapshot']) || ! is_array($source) ||
Arr::sortRecursive($payload['organization_country_snapshot']) !== Arr::sortRecursive($source))) {
throw ValidationException::withMessages(['payload.organization_country_snapshot' => 'A proveniência é recuperada no servidor; não pode ser substituída pelo formulário.']);
}
unset($payload['organization_country_snapshot']);
if ($source && ($row->payload['incorporation_country_code'] ?? null) === ($payload['incorporation_country_code'] ?? null)) {
$payload['organization_country_snapshot'] = $source;
}
return $payload;
}
public function submit(Request $request, string $id, InterestSubmission $intake): RedirectResponse|JsonResponse
{
// Replaying a completed submission returns only the owner's existing receipt.
@@ -239,11 +315,12 @@ class InvestmentSubmissionController extends Controller
return InterestRequest::findOrFail($row->interest_request_id)->reference;
}
abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.');
$opportunity = $row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null;
$scopeKey = $data['payload']['context_opportunity'] ?? null;
$opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey,12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null);
if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
}
$p = $data['payload'];
$p = $this->preserveCountrySource($row, $data['payload']);
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
if ($zoneOrigin) {
$zone = app(InvestmentZones::class)->visible()->whereKey($zoneOrigin->zone_id)->lockForUpdate()->firstOrFail();
@@ -256,12 +333,18 @@ class InvestmentSubmissionController extends Controller
$interest = $intake->receive(['email' => $p['email'], 'fullName' => $p['full_name'], 'message' => $p['objective'], 'submission_id' => $id], $id,
fn () => InterestRequest::create(['reference' => 'IR-'.now()->year.'-'.Str::ulid(), 'opportunity_id' => $opportunity?->id,
'opportunity_reference' => $opportunity?->reference, 'full_name' => $p['full_name'], 'email' => $p['email'], 'phone' => $p['phone'] ?? null,
'country' => $p['country'], 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa',
'country' => $p['country'] ?? 'Não informado', 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa',
'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(['payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
$row->update(['opportunity_id'=>$opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
@@ -293,7 +376,8 @@ class InvestmentSubmissionController extends Controller
abort_unless($key !== false, 500);
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'investor-submission', 'file_name' => basename($file->getClientOriginalName()),
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => hash_file('sha256', Storage::disk('local')->path($key)),
'status' => 'active', 'data_classification' => 'confidential']);
'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'confidential']);
DB::table('investment_submission_attachments')->insert(['id' => (string) Str::uuid(), 'submission_id' => $id, 'document_id' => $documentId, 'created_at' => now(), 'updated_at' => now()]);
$row->increment('version');
$this->event($row, $request, 'attachment_added');
@@ -307,6 +391,8 @@ class InvestmentSubmissionController extends Controller
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Anexo privado guardado.');
}
@@ -314,23 +400,34 @@ class InvestmentSubmissionController extends Controller
{
$this->owned($request, $id);
$doc = DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')->first(['d.file_name', 'd.storage_key', 'd.sha256']);
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.malware_scanned_sha256']);
abort_unless($doc !== null, 404);
$root = realpath(Storage::disk('local')->path('investment-submissions/'.$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($doc->sha256, $hash), 404);
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash)
&& hash_equals($doc->sha256, (string) $doc->malware_scanned_sha256), 404);
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
private function event(InvestmentSubmission $row, Request $request, string $action): void
{
$boundCase = app(VirtualDesk::class)->installed() ? DB::table('desk_cases')->where('submission_id', $row->id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
$binding = null;
if ($boundCase) {
$binding = app(InvestorContextConsumers::class)->submissionRevision($row->id);
if ($action === 'submitted' || $request->boolean('context_reviewed')) {
$binding = app(InvestorContextConsumers::class)->assertReviewed($request->user(), $boundCase->id, $request->all(), $binding);
}
}
app(InvestmentWorkspace::class)->snapshot($row);
DB::table('investment_submission_events')->insert(['submission_id' => $row->id, 'actor_id' => $request->user()->id, 'action' => $action, 'version' => $row->version, 'created_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'investment-submission', 'entity_type' => 'investment_submissions',
'entity_id' => $row->id, 'actor_user_id' => $request->user()->id, 'action' => $action,
'after_data' => json_encode(['version' => $row->version, 'status' => $row->status], JSON_THROW_ON_ERROR)]);
'after_data' => json_encode(['version' => $row->version, 'status' => $row->status, ...($binding !== null ? ['investment_context_revision' => $binding] : [])], JSON_THROW_ON_ERROR)]);
}
}
@@ -2,14 +2,40 @@
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\InvestmentZones;
use App\Services\PortalProfileAccess;
use App\Services\TerritoryGeometry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
class InvestmentTerritoryController extends Controller
{
public function geometries(Request $request): JsonResponse
{
$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);
}
return response()->json([
...app(TerritoryGeometry::class)->context($data['territory_id'], $internal),
'geometry_scope' => $internal ? 'authorized_internal' : 'public',
])->header('Cache-Control', 'private, no-store');
}
private const TABLES = ['opportunities' => 'opportunities', 'projects' => 'projects', 'clusters' => 'economic_clusters', 'corridors' => 'development_corridors', 'zones' => 'investment_zones'];
public function subjects(Request $request): JsonResponse
@@ -17,6 +43,9 @@ class InvestmentTerritoryController extends Controller
$data = $request->validate(['kind' => ['required', 'in:'.implode(',', array_keys(self::TABLES))], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$column = $data['kind'] === 'opportunities' ? 'title' : 'name';
$query = DB::table(self::TABLES[$data['kind']])->whereNull('deleted_at');
if ($data['kind'] === 'opportunities' && Schema::hasColumn('opportunities', 'owner_user_id')) {
$query->whereIn('id', app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->select('id'));
}
if (! empty($data['q'])) {
$query->whereRaw($column.' ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
}
@@ -26,17 +55,26 @@ class InvestmentTerritoryController extends Controller
public function options(Request $request): JsonResponse
{
if ($request->is('minhas-oportunidades/*')) {
$access = app(PortalProfileAccess::class);
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
}
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $data['type'])
->whereIn('data_classification', ['public', 'internal']);
if ($data['type'] !== 'province') {
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']];
if (! DB::table('geo_locations')->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->where('location_type', $parentType)->exists()) {
throw ValidationException::withMessages(['parent_id' => 'Seleccione um pai activo do nível anterior.']);
}
$query->where('parent_id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000');
}
if (! empty($data['q'])) {
$query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
$term = '%'.addcslashes($data['q'], '%_\\').'%';
$query->where(fn ($q) => $q->whereRaw('name ILIKE ?', [$term])->orWhereRaw('code ILIKE ?', [$term])->orWhereRaw('source_code ILIKE ?', [$term]));
}
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type']));
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code']));
}
public function show(string $kind, string $id): JsonResponse
@@ -4,7 +4,9 @@ namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Models\User;
use App\Services\CountryCatalogue;
use App\Services\InvestmentWorkspace;
use App\Services\OrganizationCountry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -18,9 +20,10 @@ class InvestmentWorkspaceController extends Controller
{
public function profile(Request $request, InvestmentWorkspace $workspace): Response|JsonResponse
{
$data = ['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;
return $company;
})];
@@ -31,7 +34,8 @@ class InvestmentWorkspaceController extends Controller
public function saveProfile(Request $request): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
'payload' => ['required', 'array:phone,country,sector,experience,investment_capacity'],
'payload' => ['required', 'array:phone,country,residence_country_code,sector,experience,investment_capacity'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => ['nullable', 'string', 'max:100'],
'payload.sector' => ['nullable', 'string', 'max:150'], 'payload.experience' => ['nullable', 'string', 'max:2000'],
'payload.investment_capacity' => ['nullable', 'string', 'max:1000']]);
@@ -53,8 +57,9 @@ class InvestmentWorkspaceController extends Controller
public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
'payload' => ['required', 'array:name,country,registration,sector,role,description'],
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required', 'string', 'max:100'],
'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'],
'payload.registration' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'],
'payload.role' => ['required', 'string', 'max:150'], 'payload.description' => ['nullable', 'string', 'max:2000']]);
$result = DB::transaction(function () use ($request, $id, $data) {
@@ -65,9 +70,15 @@ class InvestmentWorkspaceController extends Controller
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();
$version = (int) $data['version'] + 1;
$previous = $row ? json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR) : [];
$payload = [...$previous, ...$data['payload']];
if (($previous['incorporation_country_code'] ?? null) !== ($payload['incorporation_country_code'] ?? null)) {
unset($payload['organization_country_snapshot']);
}
$encrypted = Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR));
DB::table('investment_company_declarations')->updateOrInsert(['id' => $id], ['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, 'company_declared', 'investment_company_declarations', $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]);
return ['id' => $id, 'version' => $version];
});
@@ -75,6 +86,36 @@ class InvestmentWorkspaceController extends Controller
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Declaração de empresa guardada.');
}
public function organizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse
{
$result = $service->save($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País de constituição guardado no cadastro mestre; submissões anteriores preservadas.');
}
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']);
$result = DB::transaction(function () use ($request, $id, $data, $service) {
$row = DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
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.');
$payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
$payload['incorporation_country_code'] = $snapshot['country'];
$payload['organization_country_snapshot'] = $snapshot;
$encrypted = Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR));
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']]);
return ['version' => $row->version + 1, 'country' => $snapshot['country']];
});
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);
@@ -0,0 +1,206 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InvestmentZone;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class InvestmentZoneAttachmentController extends Controller
{
private const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
public function store(Request $request, string $id): JsonResponse
{
$zone = InvestmentZone::whereKey($id)->firstOrFail();
$data = $request->validate([
'file' => ['required', 'file', 'mimetypes:application/pdf,image/jpeg,image/png,image/webp', 'max:20480'],
'kind' => ['required', 'in:legal_instrument,map,photo,study,other'],
'caption' => ['required', 'string', 'max:255'],
'source_reference' => ['required', 'string', 'min:5', 'max:2000'],
'source_date' => ['nullable', 'date_format:Y-m-d'],
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
'rights_basis' => ['nullable', 'string', 'max:2000'],
]);
$file = $request->file('file');
abort_unless(in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES, true), 422, 'Formato de ficheiro não permitido.');
$this->validateFileContent($file->getRealPath(), $file->getMimeType());
abort_if(DB::table('investment_zone_attachments')->where('zone_id', $id)->count() >= 20, 422, 'Máximo de vinte anexos por zona.');
$attachmentId = (string) Str::uuid();
$documentId = (string) Str::uuid();
$key = null;
try {
$key = $file->storeAs('investment-zones/'.$id, $documentId, 'local');
abort_unless($key !== false, 500, 'Não foi possível guardar o ficheiro.');
$hash = hash_file('sha256', Storage::disk('local')->path($key));
abort_unless(is_string($hash), 500, 'Não foi possível verificar o ficheiro guardado.');
$attachment = DB::transaction(function () use ($request, $zone, $data, $file, $attachmentId, $documentId, $key, $hash): object {
DB::table('documents')->insert([
'id' => $documentId, 'source_system' => 'investment-zones', 'version_no' => 1,
'data_classification' => 'confidential', 'file_name' => basename($file->getClientOriginalName()),
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => $hash,
// Follow the portal's existing file policy: no antivirus engine is configured, so uploads stay quarantined.
'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
'created_at' => now(), 'updated_at' => now(),
]);
DB::table('investment_zone_attachments')->insert([
'id' => $attachmentId, 'zone_id' => $zone->id, 'document_id' => $documentId,
'kind' => $data['kind'], 'caption' => $data['caption'], 'source_reference' => $data['source_reference'],
'source_date' => $data['source_date'] ?? null, 'rights_status' => $data['rights_status'],
'rights_basis' => $data['rights_basis'] ?? null, 'personal_data_status' => 'unknown',
'review_status' => 'pending', 'uploaded_by' => $request->user()->id, 'version_no' => 1,
'created_at' => now(), 'updated_at' => now(),
]);
$this->event($attachmentId, $request->user()->id, 'uploaded', 1, ['kind' => $data['kind'], 'sha256' => $hash]);
return DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $attachmentId)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']);
});
} catch (\Throwable $exception) {
if (is_string($key)) {
Storage::disk('local')->delete($key);
}
throw $exception;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return response()->json($attachment, 201);
}
public function review(Request $request, string $id, string $action): JsonResponse
{
abort_unless(in_array($action, ['review', 'select-public', 'withdraw-public'], true), 404);
$rules = ['version_no' => ['required', 'integer', 'min:1']];
if ($action === 'review') {
$rules += ['decision' => ['required', 'in:reviewed,rejected'],
'personal_data_status' => ['required', 'in:none,present,unknown'],
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
'rights_basis' => ['nullable', 'string', 'max:2000']];
}
$data = $request->validate($rules);
if ($action === 'select-public' || $action === 'withdraw-public') {
InvestmentZoneAccess::requireRole($request->user(), 'publishers');
}
return DB::transaction(function () use ($request, $id, $action, $data): JsonResponse {
$attachment = DB::table('investment_zone_attachments')->where('id', $id)->lockForUpdate()->first();
abort_unless($attachment !== null, 404);
abort_if((int) $attachment->version_no !== (int) $data['version_no'], 409, 'O anexo foi alterado. Recarregue antes de continuar.');
$version = (int) $attachment->version_no + 1;
$changes = ['version_no' => $version, 'updated_at' => now()];
$metadata = [];
if ($action === 'review') {
abort_if($data['rights_status'] === 'confirmed' && trim((string) ($data['rights_basis'] ?? $attachment->rights_basis)) === '', 422, 'Registe o fundamento dos direitos antes de os confirmar.');
$changes += [
'review_status' => $data['decision'], 'reviewed_by' => $request->user()->id, 'reviewed_at' => now(),
'independent_review' => (int) $attachment->uploaded_by !== (int) $request->user()->id,
'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status'],
'rights_basis' => $data['rights_basis'] ?? $attachment->rights_basis,
'publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null,
];
$metadata = ['decision' => $data['decision'], 'independent_review' => $changes['independent_review'], 'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status']];
} elseif ($action === 'select-public') {
abort_unless($attachment->review_status === 'reviewed' && $attachment->rights_status === 'confirmed'
&& trim((string) $attachment->rights_basis) !== '' && $attachment->personal_data_status === 'none', 422, 'O anexo precisa de revisão registada, fundamento dos direitos, confirmação de divulgação e verificação de ausência de dados pessoais.');
abort_unless(DB::table('documents')->where('id', $attachment->document_id)->where('status', 'active')
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')->exists(), 422,
'A análise antimalware tem de terminar sem ameaça antes da divulgação.');
abort_unless(app(InvestmentZones::class)->visible()->whereKey($attachment->zone_id)->exists(), 422, 'A zona deve estar publicada antes da divulgação do anexo.');
$changes += ['publicly_selected' => true, 'publicly_selected_at' => now(), 'publicly_selected_by' => $request->user()->id];
} else {
$changes += ['publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null];
}
DB::table('investment_zone_attachments')->where('id', $id)->update($changes);
$this->event($id, $request->user()->id, $action, $version, $metadata);
return response()->json(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $id)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']));
});
}
public function downloadPrivate(string $zoneId, string $id): BinaryFileResponse
{
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('d.status', 'active')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
abort_unless($record !== null, 404);
return $this->verifiedDownload($zoneId, $record);
}
public function downloadPublic(string $zoneId, string $id): BinaryFileResponse
{
abort_unless(app(InvestmentZones::class)->visible()->whereKey($zoneId)->exists(), 404);
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('a.publicly_selected', true)
->where('a.review_status', 'reviewed')->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none')
->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean')
->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
abort_unless($record !== null, 404);
$extension = match ($record->media_type) {
'application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', default => 'bin'
};
return $this->verifiedDownload($zoneId, $record, str_starts_with($record->media_type, 'image/'), 'zone-attachment-'.$id.'.'.$extension);
}
private function verifiedDownload(string $zoneId, \stdClass $document, bool $inline = false, ?string $downloadName = null): BinaryFileResponse
{
$root = realpath(Storage::disk('local')->path('investment-zones/'.$zoneId));
$path = realpath(Storage::disk('local')->path($document->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($document->sha256), $hash)
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
$safeName = $downloadName ?? basename($document->file_name);
return $inline
? response()->file($path, $headers + ['Content-Type' => $document->media_type, 'Content-Disposition' => 'inline; filename="'.$safeName.'"'])
: response()->download($path, $safeName, $headers);
}
private function validateFileContent(string $path, string $mime): void
{
if ($mime === 'application/pdf') {
$handle = fopen($path, 'rb');
$signature = $handle ? fread($handle, 5) : false;
if (is_resource($handle)) {
fclose($handle);
}
abort_unless($signature === '%PDF-', 422, 'O conteúdo não corresponde a um PDF válido.');
return;
}
$image = @getimagesize($path);
abort_unless($image && $image[0] > 0 && $image[1] > 0 && $image[0] * $image[1] <= 40000000, 422, 'A imagem é inválida ou excede 40 megapíxeis.');
$expected = ['image/jpeg' => IMAGETYPE_JPEG, 'image/png' => IMAGETYPE_PNG, 'image/webp' => IMAGETYPE_WEBP][$mime] ?? null;
abort_unless($expected !== null && $image[2] === $expected, 422, 'O conteúdo da imagem não corresponde ao formato declarado.');
}
/** @param array<string,mixed> $metadata */
private function event(string $attachmentId, int $actor, string $action, int $version, array $metadata = []): void
{
DB::table('investment_zone_attachment_events')->insert([
'id' => (string) Str::uuid(), 'attachment_id' => $attachmentId, 'actor_id' => $actor,
'action' => $action, 'version_no' => $version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
]);
}
}
+202 -22
View File
@@ -7,14 +7,21 @@ use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentTerritorialTraceability;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones;
use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestmentZoneProfileInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class InvestmentZoneController extends Controller
{
@@ -22,8 +29,16 @@ class InvestmentZoneController extends Controller
{
$management = $request->is('management/*', 'api/v2/investment/admin/*');
$zone = ($management ? InvestmentZone::query() : $zones->visible())->findOrFail($id);
$publicFields = ['id', 'code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'validation_status', 'updated_at'];
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'version_no']);
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
$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(\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'))
->whereNull('g.deleted_at')->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'g.parent_id', 'zl.scope_type', 'zl.source_reference', 'zl.validation_status']);
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'validation_status', 'version_no']);
$operator = DB::table('organizations')->where('id', $zone->operator_id)->whereNull('deleted_at')
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->value('name');
$field = ['zee' => 'zee_id', 'zfi' => 'zfi_id', 'industrial_park' => 'park_id'][$zone->category] ?? null;
@@ -33,7 +48,28 @@ class InvestmentZoneController extends Controller
$relations = $zones->links(true)->where('zl.zone_id', $id)->whereNotNull('zl.'.$foreign);
$related = $related->merge(InvestmentPortfolioController::visible()->whereIn($foreign, $relations->select('zl.'.$foreign))->get());
}
$props = ['record' => $management ? $zone : $zone->only($publicFields), 'source' => $source, 'operator' => $operator, 'management' => $management,
$attachmentColumns = $management
? ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'a.rights_status', 'a.rights_basis', 'a.personal_data_status',
'a.review_status', 'a.reviewed_by', 'a.reviewed_at', 'a.independent_review', 'a.publicly_selected', 'a.publicly_selected_by',
'a.version_no', 'a.uploaded_by', 'a.created_at', 'd.id as document_id', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.status as file_status',
'd.malware_scan_status', 'd.malware_failure_reason', 'd.malware_scanned_at']
: ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'd.media_type', 'd.size_bytes'];
$attachments = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.zone_id', $id)->when(! $management, fn ($query) => $query->where('d.status', 'active'))
->when(! $management, fn ($query) => $query->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256'))
->whereNull('d.deleted_at')
->when(! $management, fn ($query) => $query->where('a.publicly_selected', true)->where('a.review_status', 'reviewed')
->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none'))
->orderBy('a.created_at')->get($attachmentColumns);
$attachments->each(function ($attachment) use ($id, $management): void {
$attachment->download_url = $management
? url('/management/investment/zones/'.$id.'/attachments/'.$attachment->id)
: url('/zonas/'.$id.'/anexos/'.$attachment->id);
});
$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,
'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() : [],
'geometries' => $management ? DB::table('investment_zone_geometries')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
@@ -41,8 +77,12 @@ 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(\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,
'territorialSources' => app(InvestmentTerritorialTraceability::class)->sources('investment_zones', $id),
'attachments' => $attachments,
'revisions' => $management ? DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->limit(20)->get(['version_no', 'action', 'sha256', 'created_at']) : []];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zone', $props);
@@ -50,53 +90,185 @@ class InvestmentZoneController extends Controller
public function index(Request $request, InvestmentZones $zones): Response|JsonResponse
{
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
$management = $request->is('management/*', 'api/v2/investment/admin/*');
$query = $management ? InvestmentZone::query() : $zones->visible();
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
'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);
if ($path !== []) {
$query->whereIn('primary_territory_id', app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id));
}
$rows = $query->when($filters['category'] ?? null, fn ($q, $v) => $q->where('category', $v))
->when($filters['q'] ?? null, fn ($q, $v) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$v.'%')->orWhere('code', 'ilike', '%'.$v.'%')))
->orderBy('name')->orderBy('id')->paginate(12, $management ? ['*'] : ['id', 'code', 'name', 'name_en', 'category', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'])->withQueryString();
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path];
$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(\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();
$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);
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
$zone->setAttribute('territory_path', $territoryPath);
$zone->setAttribute('locations', DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
->where('zl.zone_id', $zone->id)->whereNull('g.deleted_at')
->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'); }
return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()),'operational_details_public'=>app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone)];
});
$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);
}
public function export(Request $request, InvestmentZones $zones): StreamedResponse
{
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
$query = $zones->search($filters)->orderBy('name')->orderBy('id');
return response()->streamDownload(function () use ($query): void {
$geography = app(InvestmentGeography::class);
$stream = fopen('php://output', 'wb');
if ($stream === false) {
throw new \RuntimeException('CSV output stream unavailable.');
}
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, ['Código', 'Nome', 'Categoria física', 'Regime', 'Desenvolvimento', 'Disponibilidade', 'Província', 'Distrito', 'Área declarada', 'Unidade', 'Fontes'], ';');
$query->chunk(200, function ($zones) use ($stream, $geography): void {
foreach ($zones as $zone) {
$territoryPath = $geography->path($zone->primary_territory_id);
$province = collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province;
$district = collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district;
$sources = collect($zone->source_evidence ?? [])->map(fn ($source) => ($source['title'] ?? '').' — '.($source['url'] ?? '').' — '.($source['source_date'] ?? 'data não informada'))->implode(' | ');
$values = [$zone->code, $zone->name, $zone->physical_category, $zone->legal_regime, $zone->development_status,
$zone->availability_status, $province, $district, $zone->area_value, $zone->area_unit, $sources];
$values = array_map(fn ($value) => is_string($value) && preg_match('/^[=+@\-\t\r]/u', $value) ? "'".$value : $value, $values);
fputcsv($stream, $values, ';');
}
});
fclose($stream);
}, 'zonas-e-parques.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
public function save(Request $request, InvestmentZones $zones, ?string $id = null): JsonResponse
{
if ($request->has('aliases_text')) {
$request->merge(['aliases' => array_values(array_unique(array_filter(preg_split('/[\r\n,;]+/u', (string) $request->input('aliases_text')) ?: [], fn ($value) => trim($value) !== '')))]);
}
if ($request->filled('historical_name')) {
$request->merge(['historical_territory' => [
'name' => $request->input('historical_name'), 'district' => $request->input('historical_district'),
'province' => $request->input('historical_province'), 'source_reference' => $request->input('historical_source_reference'),
]]);
}
if ($request->filled('activity_name')) {
$request->merge(['activities' => [['name' => $request->input('activity_name'), 'status' => $request->input('activity_status', 'potential')]]]);
}
$data = $request->validate(['version_no' => [$id ? 'required' : 'nullable', 'integer', 'min:1'],
'code' => ['required', 'string', 'max:80', Rule::unique('investment_zones')->ignore($id)],
'category' => ['required', 'in:zee,zfi,industrial_park,pending'], 'name' => ['required', 'string', 'max:255'],
'name_en' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:15000'],
'description_en' => ['nullable', 'string', 'max:15000'], 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'],
'operator_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id'],
'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'],
'operational_status' => ['nullable', 'in:operational,implementation,planned,suspended,unknown'],
'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'],
'area_components' => ['sometimes', 'array', 'max:30'], 'area_components.*' => ['array:type,value,unit,source_reference,source_date,status'],
'area_components.*.type' => ['required', 'in:total,legal,developed,pilot,available'], 'area_components.*.value' => ['nullable', 'numeric', 'min:0'],
'area_components.*.unit' => ['required', 'in:ha,km2,m2'], 'area_components.*.source_reference' => ['required', 'string', 'min:5', 'max:2000'],
'area_components.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'area_components.*.status' => ['required', 'in:reported,confirmed,disputed'],
'source_evidence' => ['sometimes', 'array', 'max:30'], 'source_evidence.*' => ['array:title,url,source_type,supports,source_date'],
'source_evidence.*.title' => ['required', 'string', 'max:255'], 'source_evidence.*.url' => ['required', 'url:https', 'max:2000'],
'source_evidence.*.source_type' => ['required', 'in:government,institutional,promoter,other'], 'source_evidence.*.supports' => ['required', 'string', 'max:500'],
'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'],
'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.*.support_document_ids.*' => ['uuid'],
'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.*.support_document_ids.*' => ['uuid'],
'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'],
'historical_territory' => ['nullable', 'array:name,district,province,source_reference'],
'historical_territory.name' => ['required_with:historical_territory', 'string', 'max:255'],
'historical_territory.district' => ['nullable', 'string', 'max:120'], 'historical_territory.province' => ['nullable', 'string', 'max:120'],
'historical_territory.source_reference' => ['nullable', 'string', 'max:2000'],
'locations' => ['sometimes', 'array', 'max:20'], 'locations.*.geo_location_id' => ['required', 'uuid', 'exists:geo_locations,id'],
'locations.*.scope_type' => ['required', 'in:legal_scope,administrative,site,influence'],
'locations.*.source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'locations.*.legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
foreach (['development_status', 'availability_status', 'area_components', 'source_evidence'] as $column) {
if (! Schema::hasColumn('investment_zones', $column)) {
unset($data[$column]);
}
}
foreach ($data['activities'] ?? [] as $index => $activity) {
if (! in_array($activity['status'], ['potential', 'proposed'], true) && trim((string) ($activity['source_reference'] ?? '')) === '') {
throw ValidationException::withMessages([
'activities.'.$index.'.source_reference' => 'Indique a fonte da actividade documentada antes de a classificar como autorizada, condicionada ou excluída.',
]);
}
}
if (! $id && $request->has('province_id')) {
$geo = $request->validate(['province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
$path = app(InvestmentGeography::class)->selection($geo);
$path = app(InvestmentGeography::class)->selection($geo, true);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
$data['province'] = $path[0]->name ?? null;
$data['district'] = $path[1]->name ?? null;
}
return response()->json($zones->save($id, $data, $request->user()->id), $id ? 200 : 201);
$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
{
if ($action === 'publish') {
InvestmentZoneAccess::requireRole($request->user(), 'publishers');
}
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return response()->json($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));
}
public function revisions(string $id): JsonResponse
@@ -105,4 +277,12 @@ class InvestmentZoneController extends Controller
return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at']));
}
public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse
{
InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers');
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return response()->json($zones->validateLocation($id, $data['version_no'], $request->user()->id));
}
}
@@ -9,6 +9,7 @@ use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -98,9 +99,15 @@ class InvestmentZoneGeometryController extends Controller
public function map(Request $request, InvestmentZones $zones): JsonResponse
{
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric']]);
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric'],
'category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'string', 'max:40'], 'legal_regime' => ['nullable', 'string', 'max:30'],
'development_status' => ['nullable', 'string', 'max:32'], 'availability_status' => ['nullable', 'string', 'max:32'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
abort_if((! empty($data['development_status']) || ! empty($data['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
$eligibleZones = $zones->search($data)->select('investment_zones.id');
$query = DB::table('investment_zone_geometries as l')->join('geo_geometries as g', 'g.id', '=', 'l.geometry_id')
->whereIn('l.zone_id', $zones->visible()->select('investment_zones.id'))->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
->whereIn('l.zone_id', $eligibleZones)->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
->whereNull('g.deleted_at')->where('g.data_classification', 'public')->when($data['zone_id'] ?? null, fn ($q, $v) => $q->where('l.zone_id', $v))
->where(fn ($q) => $q->whereNull('l.legal_source_id')->orWhereExists(fn ($s) => $s->selectRaw('1')->from('investment_legal_sources as ls')
->whereColumn('ls.id', 'l.legal_source_id')->where('ls.validation_status', 'validated')->whereDate('ls.valid_from', '<=', today())
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentGeography;
use App\Services\InvestorContext;
use App\Services\InvestorContextInstallation;
use App\Services\InvestorPath;
use Illuminate\Http\Request;
use Inertia\Inertia;
final class InvestorContextController extends Controller
{
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(\App\Services\InvestmentComplements::class)->resolve([['origin' => $data['complement_origin'], 'code' => $data['complement_code'],
'target' => $data['complement_target'] ?? null, 'territory_id' => $data['complement_territory'] ?? null]]);
}
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(\App\Services\InvestmentOpportunityChoices::class)->resolve($input, [], true);
}
public function current(Request $r, InvestorContext $context)
{
return response()->json($context->current($r->user()));
}
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)
{
$data = $r->validate(['opportunity' => 'nullable|string|max:100']);
return response()->json($context->view($r->user(), $id, $data['opportunity'] ?? null));
}
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)
{
$data = $r->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'territory' => 'nullable|uuid']);
$service = app(\App\Services\InvestmentCorridorContext::class);
return response()->json(['corridors' => collect(config('investment_corridors'))->map(fn($entry,$key)=>['key'=>$key,'name'=>$entry['name'],'provinces'=>$service->provinces($key)])->values(),
'parks' => $service->parks($data['corridor'] ?? null, $data['territory'] ?? null)]);
}
public function saveDraft(Request $r, string $id, InvestorContext $context)
{
return response()->json($context->saveDraft($r->user(), $id, $r->all()));
}
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(\App\Services\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(\App\Services\InvestmentOpportunityChoices::class)->mergeEntries($data['context']['opportunity_choices'] ?? [], $choices);
$draft = $context->draft($r->user(), $id);
if ($draft && isset($draft['opportunity_choices'])) {
$recovered = app(\App\Services\InvestmentOpportunityChoices::class)->resolve($draft['opportunity_choices'], $data['context']['opportunity_choices'] ?? [], true);
$new=array_filter($explicitChoices??[],fn($c)=>!in_array($entryKey($c),$draft['entry_sources']??[],true));
$choices = app(\App\Services\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(\App\Services\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']) : [])]);
}
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)
{
return response()->json($context->commit($r->user(), $id, $r->all()));
}
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 = \App\Models\InvestmentSubmission::where('id', $submissionId)->where('user_id', $r->user()->id)->where('status', 'draft')->firstOrFail();
abort_unless(($ref['kind'] ?? null) === 'portfolio' && $submission->dossier_id === $ref['id'], 422, 'Conserve a ficha de origem da manifestação.');
$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]);
}
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)
{
return response()->json($install->apply($r->user(),$r->all()));
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Services\Ai\EvidenceRepository;
use App\Services\Ai\InvestorGuidance;
use App\Services\InvestorPath;
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\Schema;
class InvestorGuidanceController extends Controller
{
public function contexts(Request $request, VirtualDesk $desk): JsonResponse
{
$actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$cases = DB::table('desk_cases')->whereNull('closed_at')->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', 'journey_id'])->map(function ($case) {
return ['id' => $case->id, 'title' => $case->title, 'path_supported' => app(InvestorPath::class)->available()
&& $case->journey_id && DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->exists()];
});
return response()->json(['cases' => $cases]);
}
public function show(Request $request, string $id, InvestorGuidance $guidance): JsonResponse
{
return response()->json($guidance->view($request->user(), $id));
}
public function assumptions(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$guidance->assumptions($request->user(), $id, $request->all());
return $this->completed($request, 'Pressupostos corrigidos; os dados dos módulos de origem foram preservados.');
}
public function feedback(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$guidance->feedback($request->user(), $id, $request->all());
return $this->completed($request, 'Revisão registada. Assumir uma tarefa continua a ser uma acção separada.');
}
public function task(Request $request, string $id, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$task = $guidance->acceptTask($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['id' => $task->id], 201) : back()->with('success', 'Tarefa assumida no CRM interno. Sincronização externa pendente de finalidade e autorização.');
}
public function updateTask(Request $request, string $id, string $task, InvestorGuidance $guidance): JsonResponse|RedirectResponse
{
$guidance->updateTask($request->user(), $id, $task, $request->all());
return $this->completed($request, 'Resultado da tarefa registado no mesmo histórico do dossier.');
}
public function search(Request $request, string $id, VirtualDesk $desk, EvidenceRepository $evidence): JsonResponse
{
$desk->case($request->user(), $id);
$data = $request->validate(['query' => 'required|string|min:3|max:300']);
$enabled = config('investmoz_ai.enabled') && Schema::hasTable('ai_controls') && DB::table('ai_controls')->where('capability', 'search')->where('suspended', false)->exists();
$items = $enabled ? $evidence->search($data['query'], 'pt', ['public_only' => true]) : [];
return response()->json(['mode' => 'lexical_evidence', 'model' => null, 'status' => ! $enabled ? 'unavailable' : ($items ? 'evidence_found' : 'no_evidence'),
'consulted' => $items, 'limitations' => ['Apenas excertos das fontes públicas autorizadas indexadas; não é uma consulta exaustiva nem validação jurídica.']]);
}
private function completed(Request $request, string $message): JsonResponse|RedirectResponse
{
return $request->expectsJson() ? response()->json(['message' => $message]) : back()->with('success', $message);
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers;
use App\Services\FintechInvestment;
use App\Services\InvestorPath;
use App\Services\InvestmentZonePathContext;
use App\Services\LegacyContent;
use App\Services\SectorInvestment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class InvestorPathController extends Controller
{
public function index(Request $request, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
$data = ['contexts' => $path->contexts($request->user())];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
}
public function sectorStart(Request $request, string $sectorSlug, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
$data = ['contexts' => $path->contexts($request->user()), 'sectorSource' => app(SectorInvestment::class)->context($sectorSlug)];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
}
public function sectorAttach(Request $request, string $id): JsonResponse|RedirectResponse
{
app(SectorInvestment::class)->attach($request->user(), $id, $request->all());
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
{
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
{
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
{
$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);
}
$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): string {
$id = $path->create($request->user(), $request->all());
if ($source !== null) {
$case = $path->view($request->user(), $id)['case'];
app(LegacyContent::class)->attach($request->user(), $id, ['version' => $case['version'], 'resource_id' => (int) $source]);
}
if ($sector !== null) {
$case = $path->view($request->user(), $id)['case'];
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;
});
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);
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/show', $data);
}
public function facts(Request $request, string $id, InvestorPath $path): JsonResponse|RedirectResponse
{
abort_unless($path->available(), 404);
$path->facts($request->user(), $id, $request->all());
return $this->saved($request, 'Aplicabilidade registada; o histórico foi preservado.');
}
public function stage(Request $request, string $id, string $code, InvestorPath $path): JsonResponse|RedirectResponse
{
abort_unless($path->available(), 404);
$path->stage($request->user(), $id, $code, $request->all());
return $this->saved($request, 'Etapa e fundamento registados.');
}
public function tool(Request $request, string $id, InvestorPath $path): JsonResponse
{
abort_unless($path->available(), 404);
return response()->json(['result' => $path->returnTool($request, $id, $request->all())]);
}
public function task(Request $request, string $id, InvestorPath $path): JsonResponse|RedirectResponse
{
abort_unless($path->available(), 404);
$task = $path->task($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['id' => $task], 201) : back()->with('success', 'Tarefa assumida no acompanhamento interno.');
}
public function fintech(Request $request, string $id, InvestorPath $path): JsonResponse|RedirectResponse
{
abort_unless($path->available(), 404);
app(FintechInvestment::class)->save($request->user(), $id, $request->all());
return $this->saved($request, 'Caracterização Fintech guardada no mesmo dossier; pendências e histórico preservados.');
}
private function saved(Request $request, string $message): JsonResponse|RedirectResponse
{
return $request->expectsJson() ? response()->json(['message' => $message]) : back()->with('success', $message);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Investor;
use App\Models\InvestorJourney;
use App\Services\CountryCatalogue;
use App\Services\InterestSubmission;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
@@ -23,10 +24,12 @@ class InvestorWorkspaceController extends Controller
$interest = $journey->interest_request_id ? InterestRequest::find($journey->interest_request_id) : null;
return ['id' => $journey->id, 'dossier_id' => $dossier?->id, 'title' => $dossier->title ?? 'Ficha temporariamente indisponível',
'status' => $journey->status, 'reference' => $interest?->reference, 'request_status' => $interest?->status];
'status' => $journey->status, 'reference' => $interest?->reference, 'request_status' => $interest?->status,
'country' => $interest?->country,
'declared_origin' => app(CountryCatalogue::class)->describe($interest?->metadata['declared_origin_country_code'] ?? null)];
});
return Inertia::render('portfolio/workspace', ['journeys' => $journeys]);
return Inertia::render('portfolio/workspace', ['journeys' => $journeys, 'countries' => app(CountryCatalogue::class)->all()]);
}
public function start(Request $request, string $id): RedirectResponse
@@ -57,7 +60,8 @@ class InvestorWorkspaceController extends Controller
public function submit(Request $request, string $id, InterestSubmission $submission): RedirectResponse
{
$data = $request->validate(['message' => ['required', 'string', 'min:20', 'max:5000'], 'country' => ['required', 'string', 'max:100'],
$data = $request->validate(['message' => ['required', 'string', 'min:20', 'max:5000'], 'country' => ['required', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'declared_origin_country_code' => ['nullable', app(CountryCatalogue::class)->rule()],
'privacy_consent' => ['accepted'], 'role' => ['required', 'in:investir,financiar,fornecer,operar,parceria'],
'horizon' => ['required', 'string', 'max:100']]);
DB::transaction(function () use ($request, $id, $data, $submission) {
@@ -74,6 +78,7 @@ class InvestorWorkspaceController extends Controller
'sector_interest' => Str::limit($dossier->content['sector'] ?? $dossier->title, 150, ''), 'message' => $data['message'],
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/carteira/'.$dossier->id,
'metadata' => ['journey_id' => $journey->id, 'dossier_id' => $dossier->id, 'origin_dossier_id' => $journey->origin_dossier_id,
'declared_origin_country_code' => $data['declared_origin_country_code'] ?? null,
'project_id' => $dossier->project_id ?? $origin?->project_id,
'cluster_id' => $dossier->cluster_id ?? $origin?->cluster_id, 'corridor_id' => $dossier->corridor_id ?? $origin?->corridor_id,
'role' => $data['role'], 'horizon' => $data['horizon'], 'purpose' => 'Acompanhamento da manifestação de interesse'],
@@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\StoreLandAssistanceCaseRequest;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\LandAssistanceCase;
use App\Services\CountryCatalogue;
use App\Services\InterestSubmission;
use App\Services\InvestmentGeography;
use App\Services\InvestorContext;
use App\Services\InvestorContextConsumers;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class LandAssistanceController extends Controller
{
private const DISCLAIMER = 'Este documento apoia a preparação e o acompanhamento. Não constitui requerimento oficialmente recebido, título de DUAT, confirmação de disponibilidade da terra ou decisão da autoridade competente.';
public function guide(): Response
{
return Inertia::render('land-assistance/guide', ['disclaimer' => self::DISCLAIMER]);
}
public function blank(): Response
{
return Inertia::render('land-assistance/blank', ['disclaimer' => self::DISCLAIMER]);
}
public function index(Request $request): Response
{
$cases = LandAssistanceCase::where('user_id', $request->user()->id)
->latest('updated_at')->paginate(20)->through(fn (LandAssistanceCase $case): array => [
'id' => $case->id,
'reference' => $case->reference,
'status' => $case->status,
'version' => $case->version,
'updated_at' => $case->updated_at,
]);
return Inertia::render('land-assistance/workspace', ['cases' => $cases]);
}
public function create(Request $request): Response
{
$context = $request->validate(['context' => ['nullable', 'uuid']])['context'] ?? null;
$dossier = null;
if ($context !== null) {
$case = app(VirtualDesk::class)->case($request->user(), $context);
abort_unless(config('investor-path.enabled') && (int) $case->user_id === $request->user()->id && ! $case->closed_at && $case->journey_id
&& DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(), 403);
$dossier = ['id' => $case->id, 'title' => $case->title, 'url' => '/investimentos/'.$case->id];
}
return Inertia::render('land-assistance/form', [
'case' => null, 'boundContextRevision' => null,
'returnDossier' => $dossier,
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
'disclaimer' => self::DISCLAIMER,
'modelVersion' => 'land-assistance-v1',
'countries' => app(CountryCatalogue::class)->all(),
]);
}
public function edit(Request $request, string $id): Response
{
$case = $this->owned($request, $id);
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido não está aberto para edição.');
return Inertia::render('land-assistance/form', [
'case' => $case->only(['id', 'reference', 'version', 'status', 'payload']), 'boundContextRevision' => app(InvestorContextConsumers::class)->landRevision($case->id),
'returnDossier' => $this->dossierContext($case),
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
'disclaimer' => self::DISCLAIMER,
'modelVersion' => 'land-assistance-v1',
'countries' => app(CountryCatalogue::class)->all(),
]);
}
public function store(StoreLandAssistanceCaseRequest $request): RedirectResponse|JsonResponse
{
$payload = $request->payload();
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
$payload['email'] = $payload['email'] ?? $request->user()->email;
$case = DB::transaction(function () use ($request, $payload): LandAssistanceCase {
$case = LandAssistanceCase::create([
'user_id' => $request->user()->id,
'reference' => 'TERRA-'.now()->format('Y').'-'.Str::upper(Str::random(8)),
'status' => 'draft',
'version' => 1,
'payload' => $payload,
'crm_submission_key' => (string) Str::uuid(),
]);
$this->revision($case);
$this->event($case, $request, 'draft_created');
return $case;
});
return $request->expectsJson()
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version], 201)
: redirect()->route('land-assistance.show', $case->id);
}
public function show(Request $request, string $id): Response|JsonResponse
{
$case = $this->owned($request, $id);
$data = [
...$this->presentation($case),
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')
->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
'events' => DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')
->get(['action', 'version', 'metadata', 'created_at']),
'supportTicket' => $case->interest_request_id
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
: null,
'disclaimer' => self::DISCLAIMER,
'management' => false,
];
return $request->expectsJson() ? response()->json($data) : Inertia::render('land-assistance/case', $data);
}
public function save(StoreLandAssistanceCaseRequest $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validated();
$payload = $request->payload();
$case = DB::transaction(function () use ($request, $id, $data, $payload): LandAssistanceCase {
$case = $this->owned($request, $id, true);
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido está bloqueado para edição pelo requerente.');
$this->checkVersion($case, (int) $data['version']);
$payload['full_name'] = $payload['full_name'] ?? $case->payload['full_name'] ?? $request->user()->name;
$payload['email'] = $payload['email'] ?? $case->payload['email'] ?? $request->user()->email;
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
$this->revision($case);
$this->event($case, $request, 'draft_updated');
return $case;
});
return $request->expectsJson()
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version])
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Rascunho guardado.');
}
public function requestSupport(StoreLandAssistanceCaseRequest $request, string $id, InterestSubmission $submission): RedirectResponse|JsonResponse
{
$data = $request->validated();
$submittedPayload = $request->payload();
$case = DB::transaction(function () use ($request, $id, $data, $submission, $submittedPayload): LandAssistanceCase {
if ($boundId = $submittedPayload['investor_path_case_id'] ?? null) {
app(InvestorContextConsumers::class)->assertReviewed($request->user(), $boundId, $request->all(), app(InvestorContextConsumers::class)->landRevision($id));
}
$case = $this->owned($request, $id, true);
$replaying = $case->status === 'support_requested';
abort_unless(in_array($case->status, ['draft', 'waiting_applicant', 'support_requested'], true), 409, 'Este pedido já não pode ser submetido neste estado.');
if (! $replaying) {
$this->checkVersion($case, (int) $data['version']);
}
$payload = [...$case->payload, ...$submittedPayload];
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
if (! array_key_exists('email', $payload)) {
$payload['email'] = $request->user()->email;
}
$payload['email'] = is_string($payload['email']) && trim($payload['email']) !== '' ? strtolower(trim($payload['email'])) : null;
abort_if(empty($payload['email']) && empty($payload['phone']), 422, 'Indique pelo menos um contacto valido.');
if ($replaying) {
// A retry of the same accepted attempt recovers its original receipt.
// Changed data, another version or a missing receipt must not reopen the case.
abort_unless((int) $case->version === (int) $data['version'] + 1 && $case->interest_request_id
&& hash_equals(hash('sha256', json_encode(Arr::sortRecursive($case->payload), JSON_THROW_ON_ERROR)),
hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR))),
409, 'O pedido mudou; recupere o estado atual antes de voltar a enviar.');
}
$message = trim(($payload['request_description'] ?? 'Pedido de apoio sobre Terra e DUAT.')."\nReferência interna: {$case->reference}. Tipo: ".($payload['support_kind'] ?? 'não indicado').'.');
$interest = $submission->receive([
'kind' => 'support', 'fullName' => $payload['full_name'], 'email' => $payload['email'],
'phone' => $payload['phone'] ?? null, 'country' => $payload['country'] ?? 'Não indicado',
'sectorInterest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
'privacyConsent' => true, 'portal_user_id' => $request->user()->id,
'land_case_id' => $case->id, 'land_case_reference' => $case->reference,
'declared_origin_country_code' => $payload['declared_origin_country_code'] ?? null,
], $case->crm_submission_key, fn () => InterestRequest::create([
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $payload['full_name'],
'email' => $payload['email'], 'phone' => $payload['phone'] ?? null,
'country' => $payload['country'] ?? 'Não indicado',
// Use the existing intake vocabulary; retain the more specific declared category.
// This factory only runs for a new receipt. Older submissions/replay hashes stay intact.
'investor_type' => match ($payload['applicant_category'] ?? null) {
'individual' => 'individual', 'entity' => 'empresa', default => 'outro',
},
'sector_interest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/terra-duat',
'metadata' => ['kind' => 'support', 'portal_user_id' => $request->user()->id,
'land_case_id' => $case->id, 'land_case_reference' => $case->reference,
'applicant_category' => $payload['applicant_category'] ?? null,
'declared_origin_country_code' => $payload['declared_origin_country_code'] ?? null],
]));
if ($replaying) {
abort_unless((int) $interest->getKey() === (int) $case->interest_request_id && ! $interest->trashed(),
409, 'O recibo original não está disponível; recupere o estado do pedido.');
return $case;
}
$case->forceFill([
'payload' => $payload,
'status' => 'support_requested',
'support_requested_at' => now(),
'version' => $case->version + 1,
'interest_request_id' => $interest->id,
])->save();
$this->revision($case);
$this->event($case, $request, 'support_requested');
return $case;
});
return $request->expectsJson()
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'status' => $case->status, 'version' => $case->version], 202)
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Pedido de apoio registado no InvestMoz. Isto não é submissão oficial de DUAT.');
}
public function reportUpdate(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validate([
'version' => ['required', 'integer', 'min:1'],
'reported_status' => ['required', 'string', 'max:1000'],
'official_reference' => ['nullable', 'string', 'max:160'],
'receiving_entity' => ['nullable', 'string', 'max:200'],
'reported_at' => ['nullable', 'date_format:Y-m-d'],
]);
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
$case = $this->owned($request, $id, true);
abort_unless(in_array($case->status, ['support_requested', 'internal_review', 'waiting_applicant', 'referral_prepared'], true), 409);
$this->checkVersion($case, (int) $data['version']);
$payload = $case->payload;
$updates = $payload['reported_updates'] ?? [];
$updates[] = ['reported_status' => $data['reported_status'], 'official_reference' => $data['official_reference'] ?? null,
'receiving_entity' => $data['receiving_entity'] ?? null, 'reported_at' => $data['reported_at'] ?? null, 'recorded_at' => now()->toIso8601String()];
$payload['reported_updates'] = array_slice($updates, -20);
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
$this->revision($case);
$this->event($case, $request, 'applicant_update_recorded');
return $case;
});
return $request->expectsJson()
? response()->json(['version' => $case->version, 'reported_by_applicant' => true])
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Actualização registada como informação comunicada por si.');
}
public function attach(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validate([
'version' => ['required', 'integer', 'min:1'],
'category' => ['required', Rule::in(['sketch', 'prior_right', 'exploration_plan', 'official_receipt', 'consultation_minutes', 'photo', 'other'])],
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
]);
$case = $this->owned($request, $id, true);
$this->checkVersion($case, (int) $data['version']);
abort_unless(! in_array($case->status, ['closed'], true), 409);
abort_if(DB::table('land_assistance_documents')->where('case_id', $id)->count() >= 20, 422, 'Máximo de vinte documentos por pedido.');
$file = $request->file('file');
$documentId = (string) Str::uuid();
$key = 'land-assistance/'.$id.'/'.$documentId;
$stored = $file->storeAs('land-assistance/'.$id, $documentId, 'local');
if (! is_string($stored) || $stored === '') {
throw ValidationException::withMessages(['file' => 'Não foi possível guardar o ficheiro em armazenamento privado.']);
}
try {
DB::transaction(function () use ($request, $case, $data, $file, $documentId, $stored): void {
$locked = $this->owned($request, $case->id, true);
$this->checkVersion($locked, (int) $data['version']);
DB::table('documents')->insert([
'id' => $documentId,
'source_system' => 'land-assistance',
'data_classification' => 'restricted',
'file_name' => basename($file->getClientOriginalName()),
'media_type' => (string) $file->getMimeType(),
'storage_key' => $stored,
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)),
'size_bytes' => $file->getSize(),
'uploaded_by' => $request->user()->id,
// The configured scanner releases only the exact original hash after a clean result.
'status' => 'quarantined',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('land_assistance_documents')->insert([
'id' => (string) Str::uuid(), 'case_id' => $locked->id, 'document_id' => $documentId,
'category' => $data['category'], 'created_at' => now(),
]);
$locked->forceFill(['version' => $locked->version + 1])->save();
$this->revision($locked);
$this->event($locked, $request, 'attachment_added', ['document_id' => $documentId, 'category' => $data['category']]);
});
} catch (\Throwable $exception) {
Storage::disk('local')->delete($stored);
throw $exception;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return $request->expectsJson()
? response()->json(['uploaded' => true], 201)
: back()->with('status', 'Documento privado anexado; a revisão ainda está pendente.');
}
public function download(Request $request, string $id, string $attachment): \Illuminate\Http\Response|BinaryFileResponse
{
// Management uses its existing explicit permission; the owner URL stays owner-only.
if ($request->routeIs('land-assistance.manage.download')) {
abort_unless($request->user()->can('manage-portal'), 403);
$case = LandAssistanceCase::whereKey($id)->firstOrFail();
} else {
$case = $this->owned($request, $id);
}
$document = DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
->where('link.case_id', $id)->where('link.id', $attachment)->where('document.status', 'active')->whereNull('document.deleted_at')
->where('document.malware_scan_status', 'clean')->whereColumn('document.malware_scanned_sha256', 'document.sha256')
->first(['document.*']);
abort_if($document === null, 404);
$response = app(VerifiedDocumentBinary::class)->respond($document);
DB::table('audit_events')->insert(['source_system' => 'land-assistance', 'entity_type' => 'documents', 'entity_id' => $document->id,
'actor_user_id' => $request->user()->id, 'action' => 'document_downloaded', 'data_classification' => 'restricted',
'after_data' => json_encode(['case_id' => $case->id, 'attachment_id' => $attachment], JSON_THROW_ON_ERROR)]);
return $response;
}
public function print(Request $request, string $id): Response
{
$case = $this->owned($request, $id);
return Inertia::render('land-assistance/print', [
'case' => $case->only(['reference', 'version', 'payload', 'updated_at']),
'disclaimer' => self::DISCLAIMER,
'modelVersion' => 'land-assistance-v1',
'country' => app(CountryCatalogue::class)->describe($case->payload['country'] ?? null),
'declaredOrigin' => app(CountryCatalogue::class)->describe($case->payload['declared_origin_country_code'] ?? null),
]);
}
/** @return array<string,mixed> */
private function presentation(LandAssistanceCase $case, bool $management = false): array
{
$payload = $case->payload;
$countries = app(CountryCatalogue::class);
$country = $countries->describe($payload['country'] ?? null);
$paths = [];
foreach ($payload['parcels'] ?? [] as $parcel) {
$id = $parcel['locality_id'] ?? $parcel['admin_post_id'] ?? $parcel['district_id'] ?? $parcel['province_id'] ?? null;
$paths[] = app(InvestmentGeography::class)->path($id ?: null, $management);
}
return ['countryName' => $country['code'] !== null ? $country['name'] : ($payload['country'] ?? 'Não informado'),
'declaredOrigin' => $countries->describe($payload['declared_origin_country_code'] ?? null), 'territoryPaths' => $paths, 'returnDossier' => $this->dossierContext($case)];
}
private function dossierContext(LandAssistanceCase $case): ?array
{
$id = $case->payload['investor_path_case_id'] ?? null;
$actor = request()->user();
if (! $actor || (int) $case->user_id !== $actor->id || ! is_string($id) || ! Str::isUuid($id) || ! config('investor-path.enabled')) {
return null;
}
$dossier = DB::table('desk_cases')->where('id', $id)->where('user_id', $actor->id)->first();
return $dossier ? ['id' => $dossier->id, 'title' => $dossier->title, 'url' => '/investimentos/'.$dossier->id] : null;
}
public function manage(Request $request): Response
{
$cases = LandAssistanceCase::query()->latest('updated_at')->paginate(30)->through(fn (LandAssistanceCase $case): array => [
'id' => $case->id, 'reference' => $case->reference, 'status' => $case->status,
'version' => $case->version, 'updated_at' => $case->updated_at,
'applicant' => $case->payload['full_name'] ?? null,
]);
return Inertia::render('land-assistance/management', ['cases' => $cases]);
}
public function manageShow(Request $request, string $id): Response
{
$case = LandAssistanceCase::findOrFail($id);
$events = DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')->get(['action', 'version', 'metadata', 'created_at']);
return Inertia::render('land-assistance/case', [
...$this->presentation($case, true),
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
'events' => $events,
'supportTicket' => $case->interest_request_id
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
: null,
'disclaimer' => self::DISCLAIMER,
'management' => true,
]);
}
public function changeStatus(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validate([
'version' => ['required', 'integer', 'min:1'],
'status' => ['required', Rule::in(['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'])],
'evidence_reference' => ['nullable', 'string', 'max:300'],
]);
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
$case = LandAssistanceCase::whereKey($id)->lockForUpdate()->firstOrFail();
$this->checkVersion($case, (int) $data['version']);
$allowed = [
'support_requested' => ['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'],
'internal_review' => ['waiting_applicant', 'referral_prepared', 'closed'],
'waiting_applicant' => ['internal_review', 'referral_prepared', 'closed'],
'referral_prepared' => ['internal_review', 'waiting_applicant', 'closed'],
];
abort_unless(in_array($data['status'], $allowed[$case->status] ?? [], true), 409, 'Transição de estado não permitida.');
if ($data['status'] === 'referral_prepared' && empty($data['evidence_reference'])) {
throw ValidationException::withMessages(['evidence_reference' => 'Registe a referência interna do material preparado para encaminhamento.']);
}
$previousStatus = $case->status;
$case->forceFill(['status' => $data['status'], 'version' => $case->version + 1])->save();
$this->revision($case);
$this->event($case, $request, 'status_changed', [
'from' => $previousStatus, 'to' => $data['status'],
'evidence_reference' => $data['evidence_reference'] ?? null,
'external_submission_confirmed' => false,
]);
return $case;
});
return $request->expectsJson()
? response()->json(['status' => $case->status, 'version' => $case->version])
: redirect()->route('land-assistance.manage.show', $case->id)->with('status', 'Estado interno actualizado. Nenhuma recepção pública foi presumida.');
}
private function owned(Request $request, string $id, bool $lock = false): LandAssistanceCase
{
$query = LandAssistanceCase::whereKey($id)->where('user_id', $request->user()->id);
return ($lock ? $query->lockForUpdate() : $query)->firstOrFail();
}
private function checkVersion(LandAssistanceCase $case, int $expected): void
{
abort_unless($case->version === $expected, 412, 'Este rascunho foi alterado. Recarregue antes de continuar.');
}
/** @param array<string,mixed> $metadata */
private function event(LandAssistanceCase $case, Request $request, string $action, array $metadata = []): void
{
$contextId = $case->payload['investor_path_case_id'] ?? null;
if ($contextId) {
$binding = app(InvestorContextConsumers::class)->landRevision($case->id);
if ($action === 'draft_created' || $request->boolean('context_reviewed')) {
$binding = app(InvestorContextConsumers::class)->assertReviewed($request->user(), $contextId, $action === 'draft_created' ? ['context_reviewed' => true, 'context_revision' => app(InvestorContext::class)->state(app(VirtualDesk::class)->case($request->user(), $contextId))['revision']] : $request->all(), $binding);
}
$metadata['investment_context_revision'] = $binding;
}
DB::table('land_assistance_events')->insert([
'case_id' => $case->id, 'actor_id' => $request->user()->id,
'action' => $action, 'version' => $case->version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
]);
}
private function revision(LandAssistanceCase $case): void
{
DB::table('land_assistance_revisions')->insert([
'case_id' => $case->id, 'version' => $case->version,
'snapshot' => Crypt::encryptString(json_encode(['status' => $case->status, 'payload' => $case->payload], JSON_THROW_ON_ERROR)),
'created_at' => now(),
]);
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class LegacyContentController extends Controller
{
public function landing(Request $request, LegacyContent $legacy, string $sectorSlug = 'agronegocio'): Response
{
$filters = $request->validate(['q' => 'nullable|string|max:120']);
$articles = $legacy->articlesForGroup($sectorSlug);
$root = config('legacy_content.groups.'.$sectorSlug.'.root');
$article = $legacy->article($root);
$q = $filters['q'] ?? '';
$articles = array_values(array_filter($articles, fn ($item) => $q === ''
|| str_contains(mb_strtolower(strip_tags($item['html'].' '.$item['title'])), mb_strtolower($q))));
$match = $articles !== [];
return Inertia::render('portal/legacy', ['article' => $article, 'articles' => $articles, 'view' => 'landing', 'query' => $q, 'match' => $match]);
}
public function article(Request $request, LegacyContent $legacy, string $legacySlug = 'agronegocio'): Response
{
return Inertia::render('portal/legacy', ['article' => $legacy->article($legacySlug), 'view' => 'article', 'contentContext' => $this->context($request)]);
}
public function start(Request $request, LegacyContent $legacy, InvestorPath $path, string $legacySlug = 'agronegocio'): Response
{
abort_unless($path->available(), 404);
$article = $legacy->article($legacySlug);
return Inertia::render('investor-path/index', ['contexts' => $path->contexts($request->user()), 'contentSource' => $article]);
}
public function attach(Request $request, string $id, LegacyContent $legacy): JsonResponse|RedirectResponse
{
$legacy->attach($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['saved' => true]) : redirect('/investimentos/'.$id)->with('success', 'Referência guardada no dossier.');
}
/** @return array{id: string, title: string}|null */
private function context(Request $request): ?array
{
$data = $request->validate(['context' => 'nullable|uuid']);
if (! $request->user() || empty($data['context'])) {
return null;
}
try {
$case = app(VirtualDesk::class)->case($request->user(), $data['context'], true);
return $case->closed_at ? null : ['id' => $case->id, 'title' => $case->title];
} catch (HttpException $error) {
if (! in_array($error->getStatusCode(), [403, 404], true)) {
throw $error;
}
return null; // Consultation stays public; an unauthorized private context is never returned.
}
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityClassification;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OpportunityClassificationController extends Controller
{
public function index(Request $request, int $id, OpportunityClassification $service): JsonResponse
{
$record = Opportunity::findOrFail($id);
app(PortalProfileAccess::class)->authorize($request->user(), 'view', $record);
return response()->json(['version' => $service::VERSION, 'etag' => app(OpportunityManagement::class)->etag($record), 'rows' => $service->listing($request->user(), $record), 'duplicates' => $service->duplicates($request->user(), $record)])->header('Cache-Control', 'private, no-store');
}
public function review(Request $request, int $id, OpportunityClassification $service): JsonResponse
{
$record = $service->review($request->user(), Opportunity::findOrFail($id), $request->all());
return response()->json(['etag' => app(OpportunityManagement::class)->etag($record), 'rows' => $service->listing($request->user(), $record)])->header('Cache-Control', 'private, no-store');
}
}
@@ -0,0 +1,179 @@
<?php
namespace App\Http\Controllers;
use App\Domain\Opportunities\Enums\OpportunityStatus;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentGeography;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityCanonicalResponse;
use App\Services\OpportunityManagement;
use App\Services\OpportunityManagementInstallation;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityResources;
use App\Services\OpportunityWorkflowTasks;
use App\Services\PortalProfileAccess;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class OpportunityManagementController extends Controller
{
public function installation(Request $request, OpportunityManagementInstallation $service): JsonResponse|Response
{
return $request->expectsJson() ? response()->json($service->preview()) : Inertia::render('management/opportunity-install', $service->preview());
}
public function applyInstallation(Request $request, OpportunityManagementInstallation $service): RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
$service->apply($request->user(), $data['hash']);
return back()->with('success', 'Gestão instalada; nenhuma oportunidade foi submetida ou publicada.');
}
public function index(Request $request, OpportunityManagement $service): Response
{
if (! $service->installed()) {
return app(PortalManagementController::class)->catalog($request, 'opportunities');
}
$filters = $request->validate(['queue' => 'nullable|in:review,publication,appeal,assigned', 'q' => 'nullable|string|max:150', 'status' => ['nullable', Rule::in([...array_column(OpportunityStatus::cases(), 'value'), 'returned', 'auto_validation_failed', 'technical_review', 'legal_territorial_review', 'approval_pending', 'publication_pending'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])], 'category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)], 'deleted' => 'nullable|boolean', 'page' => 'nullable|integer|min:1']);
$query = Opportunity::query();
$access = app(PortalProfileAccess::class);
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$query = $access->scope($query, $request->user());
if (str_starts_with($request->path(), 'minhas-oportunidades')) {
$query->whereNotNull('owner_user_id');
}
}
if ($filters['deleted'] ?? false) {
$query->withoutGlobalScope(SoftDeletingScope::class)->whereNotNull('opportunities.deleted_at');
}
if ($filters['q'] ?? null) {
$query->where(function ($q) use ($filters) {
$q->whereLike('title', '%'.$filters['q'].'%')->orWhereLike('reference', '%'.$filters['q'].'%');
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$q->orWhere(fn ($draft) => $draft->whereNotNull('owner_user_id')->whereRaw("management_draft::jsonb->'canonical'->>'official_title' ILIKE ?", ['%'.$filters['q'].'%']));
}
});
}
if ($filters['status'] ?? null) {
if (Schema::hasColumn('opportunities', 'workflow_context')) {
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)=?", [$filters['status']]);
} else {
$query->where('status', $filters['status']);
}
}
if ($filters['category'] ?? null) {
$query->where('opportunity_category', $filters['category']);
}
if ($filters['sector_id'] ?? null) {
$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']);
}
if ($filters['queue'] ?? null) {
$queue = $filters['queue'];
if ($queue === 'review') {
$query->whereRaw("COALESCE(workflow_context->>'working_state',status) IN ('submitted','in_review','information_pending')");
} elseif ($queue === 'publication') {
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)='approved'");
} elseif ($queue === 'appeal') {
$query->whereRaw("workflow_context->'appeal'->>'pending'='true'");
} else {
$query->whereExists(fn ($q) => $q->selectRaw('1')->from('tasks')->where('tasks.source_system', 'opportunity-workflow')->where('tasks.assignee_user_id', $request->user()->id)->whereIn('tasks.status', ['open', 'in_progress'])->whereRaw("tasks.external_id LIKE 'portal-opportunity:' || opportunities.id::text || ':%'"));
}
}
$base = str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities';
$stateSql = Schema::hasColumn('opportunities', 'workflow_context') ? "COALESCE(workflow_context->>'working_state',status)" : 'status';
$counts = (clone $query)->selectRaw($stateSql.' AS working_status,count(*) AS total')->groupByRaw($stateSql)->pluck('total', 'working_status');
$geographyReviewCount = (clone $query)->where('geography_scope', 'unspecified')->count();
$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(\App\Services\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);
});
return Inertia::render('management/opportunities', ['basePath' => $base, 'canCreate' => $base === '/management/opportunities' ? $access->legacyWriteAllowed($request->user()) : (bool) ($access->navigation($request->user())['contexts'] ?? []), 'records' => $records, 'filters' => $filters, 'counts' => $counts, 'categories' => OpportunityManagement::CATEGORIES, 'geographyReviewCount' => $geographyReviewCount, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug'])]);
}
public function edit(Request $request, OpportunityManagement $service, ?int $id = null): Response
{
if (! $service->installed()) {
return app(PortalManagementController::class)->edit('opportunities', $id);
}
$record = $id ? Opportunity::withTrashed()->findOrFail($id) : null;
$access = app(PortalProfileAccess::class);
if (! $record && ! str_starts_with($request->path(), 'minhas-oportunidades')) {
abort_unless($access->legacyWriteAllowed($request->user()), 403);
}
if ($record) {
$access->authorize($request->user(), 'view', $record);
}
Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities',
'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(\App\Services\OpportunityPromotion::class)->allowed($request->user(), $record) ? '/minhas-oportunidades/'.$record->id.'/divulgacao' : null,
'canonicalResponse' => $record?->owner_user_id !== null ? app(OpportunityCanonicalResponse::class)->read($request->user(), $record) : null,
'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) : [],
'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) : [];
if (! $record && $request->filled('sector_id')) {
$selection = $request->validate(['sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')]]);
$values['sector_id'] = (int) $selection['sector_id'];
$values['canonical']['sector_id'] = (int) $selection['sector_id'];
}
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', ['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']) : []]);
}
public function save(Request $request, OpportunityManagement $service, ?int $id = null): JsonResponse
{
$record = $service->save($request->user(), $request->all(), $id);
if (! $id) {
$request->session()->flash('success', 'Rascunho guardado no servidor. Pode continuar o preenchimento; não foi submetido.');
}
return response()->json(['id' => $record->id, 'etag' => $service->etag($record), 'message' => 'Rascunho guardado no servidor. Não foi submetido, aprovado ou publicado.'], $id ? 200 : 201)->header('ETag', '"'.$service->etag($record).'"');
}
public function canonicalResponse(Request $request, int $id, OpportunityCanonicalResponse $response): JsonResponse
{
return response()->json($response->read($request->user(), Opportunity::withTrashed()->findOrFail($id)))
->header('Cache-Control', 'private, no-store');
}
public function lifecycle(Request $request, int $id, OpportunityManagement $service): JsonResponse
{
abort_unless($service->installed(), 503);
$record = $service->lifecycle($request->user(), $id, $request->all());
return response()->json(['id' => $record->id, 'message' => $request->input('action') === 'restore' ? 'Restaurado como rascunho; nova revisão necessária antes de publicar.' : 'Exclusão lógica/arquivo registado; histórico e relações preservados.']);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityOriginReview;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OpportunityOriginReviewController extends Controller
{
public function index(OpportunityOriginReview $review): Response
{
return Inertia::render('management/opportunity-origin', ['records' => Opportunity::whereIn('reference', ['INV-MZ-AGR-001', 'INV-MZ-ENE-002', 'INV-MZ-TUR-003', 'INV-MZ-INF-004', 'INV-MZ-BLUE-005', 'INV-MZ-FIN-006'])->orderBy('id')->pluck('id')->map(fn ($id) => $review->state($id))->all(), 'ready' => $review->ready()]);
}
public function preview(Request $request, OpportunityOriginReview $review): JsonResponse
{
$data = $request->validate(['id' => ['required', 'integer', 'exists:opportunities,id'], 'classification' => ['required', 'in:real,example,undetermined'], 'reason' => ['required', 'string', 'max:2000']]);
return response()->json($review->preview($data['id'], $data['classification'], $data['reason']));
}
public function apply(Request $request, OpportunityOriginReview $review): JsonResponse
{
$data = $request->validate(['id' => ['required', 'integer', 'exists:opportunities,id'], 'classification' => ['required', 'in:real,example,undetermined'], 'reason' => ['required', 'string', 'max:2000'], 'snapshot_hash' => ['required', 'string', 'size:64'], 'confirm' => ['required', 'accepted']]);
return response()->json($review->apply($data['id'], $data['classification'], $data['reason'], $data['snapshot_hash'], $request->user()->id));
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityPromotion;
use Illuminate\Http\Request;
use Inertia\Inertia;
final class OpportunityPromotionController extends Controller
{
public function show(Request $request, int $id, OpportunityPromotion $service): \Inertia\Response
{
return Inertia::render('management/opportunity-promotion', $service->editor($request->user(), Opportunity::findOrFail($id)));
}
public function preview(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
{
return response()->json($service->preview($request->user(), Opportunity::findOrFail($id), $request->all()));
}
public function confirm(Request $request, int $id, OpportunityPromotion $service): \Illuminate\Http\JsonResponse
{
return response()->json($service->confirm($request->user(), $id, $request->all()));
}
}
@@ -0,0 +1,261 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\CountryCatalogue;
use App\Services\InvestmentGeography;
use App\Services\OpportunityImageProcessor;
use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceInstallation;
use App\Services\OpportunityResources;
use App\Services\OrganizationCountryInstallation;
use App\Services\PortalProfileAccess;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class OpportunityResourceController extends Controller
{
public function installation(Request $request, OpportunityResourceInstallation $service): JsonResponse|Response
{
$preview = [...$service->preview(), 'imageProcessingAvailable' => app(OpportunityImageProcessor::class)->available(), 'actionUrl' => '/management/opportunity-resources-install'];
$preview['changes'] = [...$preview['changes'], 'Código reconciliado: catálogo ISO comum; residência e constituição distintas; origem declarada guardada no payload existente e estatísticas internas autorizadas. Não converte países históricos nem usa IP.', 'Seletores territoriais reutilizados com pesquisa; filtros contextuais do catálogo e fichas territoriais consultáveis no percurso do mapa. Zero geometrias não permite desenhar estruturas sem dados.', 'Não reinstala a gestão básica nem acrescenta associações territoriais. Revisão transversal, cadastro completo em todos os campos e percursos educativos continuam parciais; consultar a matriz por módulo.'];
return $request->expectsJson() ? response()->json($preview) : Inertia::render('management/opportunity-install', $preview);
}
public function applyInstallation(Request $request, OpportunityResourceInstallation $service): RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
$service->apply($request->user(), $data['hash']);
return back()->with('success', 'Recursos instalados; nenhum ficheiro publicado ou oportunidade submetida.');
}
public function organizationCountryPreview(Request $request, OrganizationCountryInstallation $service): JsonResponse|Response
{
$plan = $service->preview($request->user());
$scope = $this->countryOperationScope($request, $plan);
$operation = $request->session()->get('portal.country_installation_operation');
if (! is_array($operation) || ($operation['scope'] ?? null) !== $scope) {
$operation = ['scope' => $scope, 'key' => (string) Str::uuid()];
$request->session()->put('portal.country_installation_operation', $operation);
}
$plan = [...$plan, 'operationKey' => $operation['key'], 'actionUrl' => '/management/opportunity-resources-install/organization-country', 'extension' => true];
return $request->expectsJson() ? response()->json($plan) : Inertia::render('management/opportunity-install', $plan);
}
public function organizationCountryApply(Request $request, OrganizationCountryInstallation $service): JsonResponse|RedirectResponse
{
// Preview authorizes the actor; the UUID remains stable until this scope changes or logout.
$plan = $service->preview($request->user());
$operation = $request->session()->get('portal.country_installation_operation');
$data = $request->validate(['idempotency_key' => 'required|uuid']);
abort_unless(is_array($operation) && ($operation['scope'] ?? null) === $this->countryOperationScope($request, $plan)
&& hash_equals($operation['key'], $data['idempotency_key']), 409, 'A operação não corresponde a esta prévia. Atualize a página antes de tentar novamente.');
$result = $service->apply($request->user(), $request->all());
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre instalado; nenhuma submissão anterior alterada.');
}
/** @param array<string, mixed> $plan */
private function countryOperationScope(Request $request, array $plan): string
{
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): Response
{
$opportunity = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
Inertia::share(['basePath' => str_starts_with(request()->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities', 'readOnly' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity)]);
abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.');
$resources = [];
Inertia::share('imageProcessingAvailable', app(OpportunityImageProcessor::class)->available());
Inertia::share('countries', app(CountryCatalogue::class)->all());
foreach (OpportunityResources::KINDS as $kind) {
$resources[$kind] = $service->listing($id, $kind);
}
foreach ($resources['locations'] as $location) {
$p = $location->payload;
$location->territoryPath = app(InvestmentGeography::class)->path($p['locality_id'] ?? $p['admin_post_id'] ?? $p['district_id'] ?? $p['province_id'] ?? null, true);
}
foreach ($resources['images'] as $image) {
$image->derivativePreviews = [];
foreach ($image->payload['derivatives'] ?? [] as $derived) {
if (DB::table('documents')->where('id', $derived['document_id'])->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists()) {
$image->derivativePreviews[] = [...$derived, 'url' => URL::temporarySignedRoute(str_starts_with(request()->path(), 'minhas-oportunidades') ? 'promoter.opportunity-image-preview' : 'management.opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])];
}
}
}
return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, '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(\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.']);
}
public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null): JsonResponse
{
$input = $request->all();
if (is_string($input['payload'] ?? null)) {
$input['payload'] = json_decode($input['payload'], true);
}
$record = $service->save($request->user(), $id, $kind, $input, $request->file('file'), $resourceId);
return response()->json(['id' => $record->id, 'message' => 'Recurso guardado no servidor. Não publicado; validação final e scan são independentes.', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))], $resourceId ? 200 : 201);
}
public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service): JsonResponse
{
$service->lifecycle($request->user(), $id, $kind, $resourceId, $request->all());
return response()->json(['message' => 'Exclusão lógica/restauração registada; original e versões preservados.']);
}
public function prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse
{
$service->prepare($request->user(), $id, $resourceId, $request->all());
return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
}
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response|BinaryFileResponse
{
$o = Opportunity::findOrFail($id);
abort_if($o->status === 'archived', 404);
$image = DB::table('opportunity_images')->where('opportunity_id', $id)->where('id', $resourceId)->whereNull('deleted_at')->first();
abort_unless($image !== null, 404);
$metadata = json_decode($image->payload, true);
abort_unless(in_array($documentId, array_column($metadata['derivatives'] ?? [], 'document_id'), true), 404);
$doc = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 423, 'Scan da derivada pendente.');
return app(VerifiedDocumentBinary::class)->respond($doc, true);
}
public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response|BinaryFileResponse
{
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$opportunity = Opportunity::findOrFail($id);
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(\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);
}
/** @param array<string,list<\stdClass>> $resources
* @return list<string>
*/
private function assessment(array $resources): array
{
$issues = [];
$locations = array_values(array_filter($resources['locations'], fn ($r) => ! $r->deleted_at));
if (count($locations) < 1) {
$issues[] = 'Localização operacional ainda não preparada; ligações históricas não são convertidas automaticamente.';
}
if (count(array_filter($locations, fn ($r) => $r->payload['is_primary'] ?? false)) !== 1) {
$issues[] = 'Escolha exatamente uma localização principal.';
}
$allocated = array_filter($locations, fn ($r) => isset($r->payload['allocation_percentage']));
if ($allocated && (count($allocated) !== count($locations) || abs(array_sum(array_map(fn ($r) => (float) $r->payload['allocation_percentage'], $allocated)) - 100) > 0.01)) {
$issues[] = 'A distribuição deve abranger todas as localizações e somar 100% (tolerância 0,01).';
}
$provinces = [];
$districts = [];
foreach ($locations as $location) {
$p = $location->payload;
$mozambican = empty($p['country_code']) || $p['country_code'] === 'MZ';
if ($mozambican && (empty($p['province_id']) || empty($p['district_id']))) {
$issues[] = 'Uma localização em Moçambique tem província ou distrito pendente.';
}
if (! $mozambican && array_filter(array_intersect_key($p, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id'])))) {
$issues[] = 'Uma localização noutro país conserva uma seleção territorial de Moçambique incompatível; corrigir explicitamente sem converter o histórico.';
}
if (empty($p['role'])) {
$issues[] = 'Uma localização tem papel pendente.';
}
if ($mozambican && ! empty($p['district_id'])) {
$districts[$p['district_id']] = true;
}
if ($mozambican && ! empty($p['province_id'])) {
$provinces[$p['province_id']] = true;
}
if (($p['role'] ?? null) === 'other' && empty($p['role_description'])) {
$issues[] = 'Papel outro exige descrição.';
}
if (empty($p['source_reference'])) {
$issues[] = 'Localização com rastreabilidade por recuperar; aceitação oficial anterior preservada.';
}
if (! empty($p['geometry'])) {
$issues[] = 'Compatibilidade espacial depende de limite oficial correspondente; geometria não confirma disponibilidade.';
}
if (isset($p['area_value']) !== isset($p['area_unit'])) {
$issues[] = 'Área exige valor e unidade juntos.';
}
}
if (count($districts) > 1 && ! array_filter($locations, fn ($r) => mb_strlen($r->payload['multi_district_rationale'] ?? '') >= 50)) {
$issues[] = 'Multidistrito/interprovincial exige fundamento operacional de 50–1000 caracteres.';
}
if ($locations) {
$issues[] = 'Modo preparado: '.(count($locations) > 1 ? 'multiple' : 'single').'; âmbito administrativo moçambicano derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : ($districts ? 'district' : 'sem seleção moçambicana'))).'. Sem conversão de contexto em implantação.';
}
foreach (['images', 'documents'] as $kind) {
foreach ($resources[$kind] as $r) {
if ($r->deleted_at) {
continue;
}
if ($r->file->malware_scan_status !== 'clean') {
$issues[] = 'Anexo '.$r->file->file_name.': scan '.$r->file->malware_scan_status.'; não é evidência de segurança.';
}
$required = $kind === 'images' ? ['title', 'caption', 'alt_text', 'author_source', 'rights_status'] : ['category', 'issuer', 'edition', 'classification'];
foreach ($required as $field) {
if (empty($r->payload[$field])) {
$issues[] = 'Anexo '.$r->file->file_name.': '.$field.' pendente.';
}
}
if ($kind === 'images') {
if (empty($r->payload['derivatives'])) {
$issues[] = 'Imagem '.$r->file->file_name.': derivadas sem EXIF ainda por preparar (GD/WebP e scan necessários).';
}
$issues[] = 'Imagem '.$r->file->file_name.': revisão de dados pessoais/direitos e aprovação de divulgação ainda pendentes.';
foreach (['title' => 5, 'caption' => 10, 'alt_text' => 10, 'author_source' => 2] as $field => $minimum) {
if (mb_strlen($r->payload[$field] ?? '') < $minimum) {
$issues[] = 'Imagem '.$r->file->file_name.': '.$field.' abaixo do mínimo para validação final.';
}
}
if (($r->payload['role'] ?? null) === 'location' && empty($r->payload['location_id'])) {
$issues[] = 'Imagem de localização exige ligação a um local ativo da mesma oportunidade.';
}
if (in_array($r->payload['role'] ?? null, ['asset', 'product', 'evidence'], true)) {
$issues[] = 'Imagem de ativo/produto/evidência: módulo e associação específica ainda pendentes.';
}
if (in_array($r->payload['rights_status'] ?? null, ['licensed', 'authorized'], true) && empty($r->payload['rights_document_id'])) {
$issues[] = 'Imagem licenciada/autorizada exige comprovativo.';
}
}
}
}
$issues[] = 'Esta conferência não submete nem publica; workflow final, revisão humana e derivados seguros permanecem separados.';
return array_values(array_unique($issues));
}
}
@@ -0,0 +1,401 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\ExistingBusiness;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\PartnershipExchange;
use App\Services\PartnershipMatching;
use App\Services\PartnershipSources;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
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;
class PartnershipExchangeController extends Controller
{
public function source(Request $request, string $type, string $id): JsonResponse
{
if (! Schema::hasTable('partnership_origins')) {
return response()->json(['available' => false, 'items' => []]);
}
app(PartnershipSources::class)->resolve($type, $id, $request->user());
return response()->json(['available' => true, 'items' => DB::table('portal_partnerships as p')->join('partnership_origins as origin', 'origin.partnership_id', '=', 'p.id')
->where('origin.type', $type)->where('origin.target_id', $id)->where('p.status', 'published')->orderByDesc('p.id')->distinct()->get(['p.id', 'p.title'])])->header('Cache-Control', 'private, no-store');
}
public function index(Request $request): Response|JsonResponse
{
$filters = $request->validate(['q' => 'nullable|string|max:120', 'sector_id' => 'nullable|integer', 'partnership_type' => 'nullable|string|max:40',
'kind' => 'nullable|in:offering,seeking', 'territory_id' => 'nullable|uuid', 'origin_type' => 'nullable|string|max:40', 'origin_id' => 'nullable|string|max:100']);
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
foreach (['sector_id', 'partnership_type', 'kind'] as $field) {
if (! empty($filters[$field])) {
if ($field === 'sector_id') {
$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'])) {
$term = '%'.$filters['q'].'%';
$query->where(fn ($q) => $q->where('p.title', 'ilike', $term)->orWhere('p.sector', 'ilike', $term)
->orWhereRaw("p.payload->>'summary' ILIKE ?", [$term])->orWhere(fn ($details) => $details->whereRaw("COALESCE(p.payload->>'confidentiality','public')='public'")->where(fn ($body) => $body->where('p.offer', 'ilike', $term)->orWhere('p.seeking', 'ilike', $term))));
}
if (! empty($filters['origin_type']) && ! empty($filters['origin_id'])) {
// Authenticate access to a private master before exposing a backlink query.
app(PartnershipSources::class)->resolve($filters['origin_type'], $filters['origin_id'], $request->user());
$query->whereExists(fn ($q) => $q->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', $filters['origin_type'])->where('target_id', $filters['origin_id']));
}
if (! empty($filters['territory_id'])) {
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $filters['territory_id'])->exists(), 422);
$ids = app(InvestmentGeography::class)->descendants($filters['territory_id']);
$investmentIds = app(InvestmentCatalogue::class)->query(['geo_location_id' => $filters['territory_id']])->pluck('id')->map(fn ($id) => (string) $id)->all();
$query->where(fn ($q) => $q->whereExists(fn ($places) => $places->selectRaw('1')->from('partnership_locations')->whereColumn('partnership_id', 'p.id')->whereIn('geo_location_id', $ids))
->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins as origin')->join('project_locations as pl', DB::raw('pl.project_id::text'), '=', 'origin.target_id')
->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'project')->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids))
->orWhereExists(fn ($origins) => $origins->selectRaw('1')->from('partnership_origins')->whereColumn('partnership_id', 'p.id')->where('type', 'investment')->whereIn('target_id', $investmentIds))
->orWhereExists(fn ($members) => $members->selectRaw('1')->from('partnership_origins as origin')
->joinSub(app(InvestmentRelations::class)->memberships()->select(['cp.cluster_id', 'cp.project_id']), 'member', DB::raw('member.cluster_id::text'), '=', 'origin.target_id')
->join('project_locations as pl', 'pl.project_id', '=', 'member.project_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'cluster')
->whereNull('pl.deleted_at')->where('pl.data_classification', 'public')->whereIn('pl.geo_location_id', $ids))
->orWhereExists(fn ($zones) => $zones->selectRaw('1')->from('partnership_origins as origin')->join('investment_zone_locations as zl', DB::raw('zl.zone_id::text'), '=', 'origin.target_id')
->join('investment_zones as zone', 'zone.id', '=', 'zl.zone_id')->whereColumn('origin.partnership_id', 'p.id')->where('origin.type', 'zone')->where('zone.publication_status', 'published')
->whereNull('zone.deleted_at')->where('zl.validation_status', 'validated')->whereIn('zl.geo_location_id', $ids)));
}
$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();
if ($request->expectsJson()) {
return response()->json($results);
}
$mapPoints = [];
foreach ($results as $partnership) {
$locations = app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $partnership->id)->get()
->map(fn ($location) => ['id' => $location->geo_location_id, 'role' => $location->role, 'source_reference' => $location->source_reference])->all()));
foreach ($locations as $location) {
if ($location['location_type'] !== 'site') {
continue;
}
foreach ($location['coordinates'] as $coordinate) {
$mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title,
'location' => $location['name'].' · '.$location['role'].' · '.$location['source_reference'], 'url' => '/parcerias/'.$partnership->id]];
}
}
foreach (app(PartnershipSources::class)->forPartnership($partnership->id) as $source) {
$context = $source['geography']['locations'] ?? [];
foreach ($source['geography']['members'] ?? [] as $member) {
$context = [...$context, ...$member['locations']];
}
foreach ($context as $location) {
foreach ($location['coordinates'] as $coordinate) {
$mapPoints[] = ['geometry' => $coordinate['geometry'], 'properties' => ['id' => count($mapPoints), 'title' => $partnership->title,
'location' => 'Contexto da origem: '.$location['name'].'; implantação da parceria não confirmada', 'url' => '/parcerias/'.$partnership->id]];
}
}
}
}
return Inertia::render('partnerships/catalogue', ['results' => $results, 'filters' => $filters, 'mapPoints' => $mapPoints, ...$this->catalogues($request)]);
}
public function dashboard(Request $request): Response
{
$actor = $request->user();
$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', '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' => DB::table('partnership_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(50)->get()]);
}
public function edit(Request $request, ?int $id = null): Response
{
$record = $id ? app(PartnershipExchange::class)->find($id) : null;
abort_if($record && ! app(PartnershipExchange::class)->owns($request->user(), $record), 403);
$origins = $record ? DB::table('partnership_origins')->where('partnership_id', $id)->get() : [];
if (! $record && $request->filled('origin_type') && $request->filled('origin_id')) {
$source = app(PartnershipSources::class)->resolve($request->string('origin_type')->toString(), $request->string('origin_id')->toString(), $request->user());
$origins = [['type' => $source['type'], 'target_id' => $source['target_id']]];
}
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)]);
}
public function save(Request $request, ?int $id = null): RedirectResponse|JsonResponse
{
$record = app(PartnershipExchange::class)->save($request->user(), $request->all(), $id);
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
{
$record = app(PartnershipExchange::class)->find($id);
$owner = $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record, false);
$moderator = $request->user() && Gate::forUser($request->user())->allows('manage-portal');
abort_unless($record->status === 'published' || $owner || $moderator, 404);
$payload = json_decode($record->payload, true);
unset($payload['_creation_hash']);
$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;
$interestData = $interests->map(function ($interest) use ($record) {
return [...(array) $interest, 'payload' => json_decode($interest->payload, true), 'assessment' => json_decode($interest->assessment, true),
'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,
'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())),
'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();
}
return $request->expectsJson() ? response()->json($props)->header('Cache-Control', 'private, no-store') : Inertia::render('partnerships/detail', $props);
}
public function transition(Request $request, int $id): RedirectResponse|JsonResponse
{
$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(['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]) : back()->with('success', 'Interesse guardado.');
}
public function decide(Request $request, int $id): RedirectResponse|JsonResponse
{
$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]) : back()->with('success', 'Candidatura atualizada.');
}
public function room(Request $request, int $id): Response|JsonResponse
{
$context = app(PartnershipExchange::class)->room($request->user(), $id);
$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')->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);
}
public function message(Request $request, int $id): RedirectResponse|JsonResponse
{
$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, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
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) {
abort_unless($existing->body === $data['body'], 409, 'Chave já utilizada com outro conteúdo.');
return;
}
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]);
});
return $request->expectsJson() ? response()->json(['sent' => true]) : back()->with('success', 'Mensagem registada.');
}
public function agreement(Request $request, int $id): RedirectResponse|JsonResponse
{
$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(['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(['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']);
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(['saved' => true]) : back()->with('success', 'Marco guardado.');
}
public function upload(Request $request, int $id): RedirectResponse|JsonResponse
{
$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');
$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(['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
{
$context = app(PartnershipExchange::class)->room($request->user(), $id);
$document = DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.room_id', $id)->where('link.id', $attachment)
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))->where('d.status', 'active')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.*']);
abort_unless($document !== null, 404);
$response = app(VerifiedDocumentBinary::class)->respond($document);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_accessed.v1', ['room_id' => $id, 'attachment_id' => $attachment], 'Documento consultado');
return $response;
}
public function share(Request $request, int $id, int $attachment): RedirectResponse|JsonResponse
{
$data = $request->validate(['shared' => 'required|boolean']);
DB::transaction(function () use ($request, $id, $attachment, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, (bool) $data['shared']);
$link = DB::table('partnership_documents')->where('room_id', $id)->where('id', $attachment)->lockForUpdate()->first();
abort_unless($link !== null && (int) $link->owner_id === $request->user()->id, 403, 'Só o autor pode autorizar ou retirar a partilha.');
DB::table('partnership_documents')->where('id', $attachment)->update(['shared' => $data['shared']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_sharing_changed.v1', ['room_id' => $id, 'attachment_id' => $attachment, 'shared' => $data['shared']], 'Autorização documental atualizada');
});
return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Autorização de partilha atualizada.');
}
public function notification(Request $request, int $id): RedirectResponse
{
abort_unless(DB::table('partnership_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]) > 0, 404);
return back();
}
/** @return array<string,mixed> */
private function catalogues(Request $request): array
{
$sources = [];
foreach (InvestmentPortfolioController::visible()->where(fn ($q) => $q->whereNotNull('project_id')->orWhereNotNull('cluster_id'))->get(['id', 'title', 'project_id', 'cluster_id']) as $dossier) {
$sources[] = ['type' => $dossier->project_id ? 'project' : 'cluster', 'target_id' => $dossier->project_id ?? $dossier->cluster_id, 'title' => $dossier->title, 'href' => '/carteira/'.$dossier->id];
}
foreach (DB::table('opportunities')->where('status', 'published')->whereNotNull('published_at')->get(['id', 'title', 'slug']) as $investment) {
$sources[] = ['type' => 'investment', 'target_id' => (string) $investment->id, 'title' => $investment->title, 'href' => '/oportunidades/'.$investment->slug];
}
foreach (DB::table('investment_zones')->where('publication_status', 'published')->whereNull('deleted_at')->get(['id', 'name']) as $zone) {
$sources[] = ['type' => 'zone', 'target_id' => $zone->id, 'title' => $zone->name, 'href' => '/zonas/'.$zone->id];
}
foreach (ExistingBusiness::query()->published()->get(['id', 'public_title', 'public_slug']) as $business) {
$sources[] = ['type' => 'business', 'target_id' => $business->id, 'title' => $business->public_title, 'href' => '/negocios/'.$business->public_slug];
}
if ($request->user()) {
foreach (DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->get(['id']) as $company) {
$sources[] = ['type' => 'company', 'target_id' => $company->id, 'title' => 'Declaração empresarial existente', 'href' => '/investidor/profile'];
}
foreach (DB::table('simulation_form_drafts')->where('owner_user_id', $request->user()->id)->whereNull('deleted_at')->get(['id', 'created_at']) as $draft) {
$sources[] = ['type' => 'simulation', 'target_id' => $draft->id, 'title' => 'Ficha de projeto · '.$draft->created_at, 'href' => '/simulador-fiscal?draft='.$draft->id];
}
}
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']) : []];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Services\PartnershipInstallation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PartnershipInstallationController extends Controller
{
public function preview(PartnershipInstallation $installation): Response
{
return Inertia::render('partnerships/installation', $installation->preview());
}
public function apply(Request $request, PartnershipInstallation $installation): RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64']);
$installation->apply($data['hash'], $request->user());
return back()->with('success', 'Bolsa de Parcerias instalada. Registos antigos preservados.');
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Mail\ContactMessageReceivedMail;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
class PortalContactController extends Controller
{
// Adapter for investidor's contact form: preserve its fields and validation,
// using the configured mail transport without adding a database dependency.
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'min:2', 'max:150'],
'email' => ['required', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:30'],
'subject' => ['required', 'string', 'min:3', 'max:100'],
'message' => ['required', 'string', 'min:10', 'max:2000'],
]);
try {
Mail::to(config('contact_messages.recipient'))
->send(new ContactMessageReceivedMail([
'name' => $data['name'], 'email' => $data['email'], 'phone' => $data['phone'] ?? null,
'subject' => $data['subject'], 'message' => $data['message'], 'platform' => 'InvesteMoz',
]));
} catch (Throwable $exception) {
Log::warning('Falha no envio de contacto do portal.', ['exception' => $exception::class]);
return response()->json(['success' => false, 'message' => 'Ocorreu um erro ao enviar a mensagem.'], 500);
}
return response()->json(['success' => true, 'message' => 'Mensagem enviada com sucesso.'], 201);
}
}
@@ -6,12 +6,20 @@ use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\CountryCatalogue;
use App\Services\DeclaredOriginStatistics;
use App\Services\InterestWorkflow;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement;
use App\Services\PortalAudit;
use App\Services\PortalProfileAccess;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
@@ -22,7 +30,7 @@ use Inertia\Response;
class PortalManagementController extends Controller
{
private const STATES = ['received', 'in_review', 'contacted', 'closed'];
private const STATES = ['received', 'submitted', 'in_review', 'reviewed', 'qualification', 'qualified', 'contacted', 'contact_accepted', 'referred', 'due_diligence', 'negotiation', 'pending', 'refused', 'converted', 'cancelled', 'closed'];
public function history(): Response
{
@@ -45,15 +53,60 @@ class PortalManagementController extends Controller
return Inertia::render('management/history', ['events' => $events]);
}
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));
}
public function interestAssignments(Request $request): Response|JsonResponse
{
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(\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.');
}
public function interests(Request $request): Response
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(self::STATES)],
'follow_up' => ['nullable', Rule::in(['overdue', 'upcoming', 'unassigned'])],
'country' => ['nullable', app(CountryCatalogue::class)->rule()],
'origin' => ['nullable', Rule::in([...array_column(app(CountryCatalogue::class)->all(), 'code'), 'unknown'])],
]);
$query = InterestRequest::query()->select([
'id', 'reference', 'full_name', 'email', 'sector_interest', 'status', 'created_at',
$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(\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);
}
if ($origin = $filters['origin'] ?? null) {
if ($origin === 'unknown') {
$query->where(fn ($q) => $q->whereNull('metadata->declared_origin_country_code')->orWhereNotIn('metadata->declared_origin_country_code', array_column(app(CountryCatalogue::class)->all(), 'code')));
} else {
$query->where('metadata->declared_origin_country_code', $origin);
}
}
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
->orWhereLike('full_name', "%{$term}%")->orWhereLike('email', "%{$term}%"));
@@ -61,40 +114,152 @@ class PortalManagementController extends Controller
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if (($filters['follow_up'] ?? null) === 'overdue') {
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '<', now())
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
} elseif (($filters['follow_up'] ?? null) === 'upcoming') {
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '>=', now())
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
} elseif (($filters['follow_up'] ?? null) === 'unassigned') {
$query->whereNull('assigned_to')->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
}
return Inertia::render('management/interests', [
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString()->through(function ($row) {
$result = $row->toArray();
$result['declaredOrigin'] = app(CountryCatalogue::class)->describe($row->metadata['declared_origin_country_code'] ?? null);
$country = app(CountryCatalogue::class)->describe($row->country);
$result['countryName'] = $country['code'] !== null ? $country['name'] : $row->country;
unset($result['metadata']);
return $result;
}),
'filters' => $filters,
'countries' => app(CountryCatalogue::class)->all(),
'originStatistics' => app(DeclaredOriginStatistics::class)->forManager($request->user(), $filters['origin'] ?? null),
]);
}
public function interest(InterestRequest $interest): Response
{
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) {
$row->note = $row->reason ? Crypt::decryptString($row->reason) : null;
unset($row->reason);
return $row;
});
$consents = DB::table('consents')->where('interest_request_id', $interest->id)
->orderBy('created_at')->get(['purpose', 'policy_version', 'status', 'granted_at', 'withdrawn_at']);
return Inertia::render('management/interest', [
'declaredOrigin' => app(CountryCatalogue::class)->describe($interest->metadata['declared_origin_country_code'] ?? null),
'interest' => $interest->only([
'id', 'reference', 'full_name', 'email', 'phone', 'investor_type', 'country',
'sector_interest', 'opportunity_reference', 'investment_range', 'message',
'privacy_consent', 'consented_at', 'status', 'created_at', 'reviewed_at', 'reviewed_by', 'metadata',
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at', 'source_page',
]),
'history' => $history,
'consents' => $consents,
'assignees' => $this->interestAssignees()->get(['id', 'name']),
]);
}
public function review(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse
{
$validated = $request->validate(['status' => ['required', Rule::in(self::STATES)]]);
DB::transaction(function () use ($request, $interest, $validated) {
$locked = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
$changed = $locked->status !== $validated['status'];
PortalAudit::save($locked, [
...$validated, 'reviewed_at' => now(), 'reviewed_by' => $request->user()->id,
], $request->user()->id);
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
if ($changed && $submission) {
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $validated['status']]);
}
});
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'],
'next_action' => ['nullable', 'required_with:next_action_due_at', 'string', 'max:180'],
'next_action_due_at' => ['nullable', 'required_with:next_action', 'date', 'after_or_equal:today'],
'assigned_to' => ['nullable', 'integer', Rule::in($this->interestAssignees()->pluck('id'))],
], [
'next_action_due_at.required_with' => 'Indique o prazo da próxima acção.',
'next_action_due_at.date' => 'Indique uma data válida para a próxima acção.',
'next_action_due_at.after_or_equal' => 'O prazo da próxima acção deve ser hoje ou uma data futura.',
'next_action.required_with' => 'Descreva a próxima acção correspondente ao prazo indicado.',
]);
$before = $interest->status;
$updated = $workflow->transition($request->user(), $interest, $validated['status'], $validated['note'] ?? null,
$validated['next_action'] ?? null, $validated['next_action_due_at'] ?? null, isset($validated['assigned_to']) ? (int) $validated['assigned_to'] : null,
$request->exists('assigned_to'), $request->exists('next_action') || $request->exists('next_action_due_at'));
PortalAudit::save($updated, ['status' => $updated->status, 'reviewed_at' => $updated->reviewed_at, 'reviewed_by' => $updated->reviewed_by], $request->user()->id);
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
if ($before !== $updated->status && $submission) {
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $updated->status]);
}
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'status' => $validated['status']]) : back()->with('success', 'Estado do pedido actualizado.');
return $request->expectsJson() ? response()->json(['reference' => $updated->reference, 'status' => $updated->status]) : back()->with('success', 'Estado do pedido actualizado.');
}
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
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');
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de partilha revogado.');
}
public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
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');
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'contact_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de contacto revogado.');
}
private function withdrawConsent(Request $request, InterestRequest $interest, string $reason, string $purpose): void
{
DB::transaction(function () use ($request, $interest, $reason, $purpose) {
$lockedInterest = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id);
$consent = DB::table('consents')->where('interest_request_id', $lockedInterest->id)
->where('purpose', $purpose)->where('status', 'granted')->whereNull('deleted_at')
->lockForUpdate()->first();
if (! $consent) {
return;
}
DB::table('consents')->where('id', $consent->id)->update(['status' => 'withdrawn', 'withdrawn_at' => now(), 'updated_at' => now()]);
$correlationId = (string) Str::uuid();
DB::table('business_workflow_transitions')->insert([
'id' => (string) Str::uuid(), 'interest_request_id' => $lockedInterest->id,
'from_status' => $lockedInterest->status, 'to_status' => $lockedInterest->status,
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($reason),
'correlation_id' => $correlationId,
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
'purpose' => $purpose], JSON_THROW_ON_ERROR),
'created_at' => now(),
]);
DB::table('outbox_events')->insertOrIgnore([
'source_system' => 'portal-intake', 'external_id' => 'InterestConsentWithdrawn:'.$consent->id,
'aggregate_type' => 'interest_consent', 'aggregate_id' => $consent->id,
'event_type' => 'InterestConsentWithdrawn', 'data_classification' => 'confidential',
'correlation_id' => $correlationId, 'status' => 'pending',
'payload' => json_encode(['schema_version' => 1, 'consent_id' => $consent->id,
'interest_id' => $lockedInterest->integration_id, 'purpose' => $purpose], JSON_THROW_ON_ERROR),
]);
});
}
/** @return Builder<User> */
private function interestAssignees(): Builder
{
$emails = array_unique(array_merge(config('portal.managers', []), ...array_map(
fn (string $designation) => config('existing-business.'.$designation, []),
['triagers', 'reviewers', 'approvers_l1', 'approvers_l2', 'publishers'],
)));
return User::query()->whereNotNull('email_verified_at')->where(function ($q) use ($emails) {
$q->whereIn(DB::raw('lower(email)'), $emails)->orWhereIn('id', DB::table('portal_superuser_pair')->select('first_user_id'))->orWhereIn('id', DB::table('portal_superuser_pair')->select('second_user_id'));
})->orderBy('name');
}
public function catalog(Request $request, string $resource): Response
@@ -104,9 +269,19 @@ class PortalManagementController extends Controller
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in($resource === 'sectors' ? ['active', 'inactive'] : ['draft', 'published', 'archived'])],
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])],
'page' => ['nullable', 'integer', 'min:1'],
]);
$query = $model::query();
if ($resource === 'opportunities') {
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
$query = app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user());
}
$query->with('primaryTerritory:id,name,location_type,source_system,source_code');
if (isset($filters['geography_scope'])) {
$query->where('geography_scope', $filters['geography_scope']);
}
}
if ($term = trim($filters['q'] ?? '')) {
$query->where(function ($query) use ($term, $resource) {
$query->whereLike($resource === 'sectors' ? 'name' : 'title', "%{$term}%")
@@ -126,30 +301,39 @@ class PortalManagementController extends Controller
}
}
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
$query->where('sector_id', $filters['sector_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', [
'resource' => $resource,
'records' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
'filters' => $filters,
'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [],
'sectors' => $resource === 'opportunities' ? Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']) : [],
'geographyReviewCount' => $resource === 'opportunities'
? (clone $query)->where('geography_scope', 'unspecified')->count()
: 0,
]);
}
public function edit(string $resource, ?int $id = null): Response
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : null;
return Inertia::render('management/editor', [
'resource' => $resource,
'record' => $id ? $model::findOrFail($id) : null,
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
'record' => $record,
'territoryPath' => $record instanceof Opportunity ? app(InvestmentGeography::class)->path($record->primary_territory_id, true) : [],
'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
]);
}
public function save(Request $request, string $resource, ?int $id = null): RedirectResponse
{
if ($resource === 'opportunities' && app(OpportunityManagement::class)->installed()) {
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::findOrFail($id) : new $model;
$rules = $resource === 'sectors' ? [
@@ -159,6 +343,7 @@ class PortalManagementController extends Controller
'description' => ['nullable', 'string', 'max:20000'],
'sort_order' => ['required', 'integer', 'min:0', 'max:2147483647'],
'is_active' => ['required', 'boolean'],
'editorial_published' => ['required', 'boolean'],
] : [
'sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'reference' => ['required', 'string', 'max:80', Rule::unique('opportunities')->ignore($record->id)],
@@ -178,19 +363,17 @@ class PortalManagementController extends Controller
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['required', 'boolean'],
];
if ($resource === 'opportunities' && ! $id) {
if ($resource === 'opportunities') {
$rules['geography_scope'] = ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])];
foreach (InvestmentGeography::FIELDS as $field => $type) {
$rules[$field] = ['nullable', 'uuid'];
}
$rules['territory_version'] = [$id && $request->hasAny(array_keys(InvestmentGeography::FIELDS)) ? 'required' : 'nullable', 'integer', 'min:0'];
}
$data = $request->validate($rules);
if ($resource === 'opportunities' && ! $id && $request->has('province_id')) {
$path = app(InvestmentGeography::class)->selection($data);
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
$data['province'] = $path[0]->name ?? null;
$data['district'] = $path[1]->name ?? null;
if ($resource === 'sectors') {
$data['published_at'] = $data['editorial_published'] ? ($record->published_at ?? now()) : null;
unset($data['editorial_published']);
}
if ($resource === 'opportunities' && $data['status'] === 'published' && $record->exists
&& Schema::hasTable('education_investment_proposals')
@@ -205,7 +388,30 @@ class PortalManagementController extends Controller
$data['maturity_label'] = $labels[$data['maturity_level'] ?? 0] ?? null;
$data['published_at'] = $data['status'] === 'published' ? ($record->published_at ?? now()) : null;
}
PortalAudit::save($record, $data, $request->user()->id);
DB::transaction(function () use ($record, $data, $request, $resource) {
if ($resource === 'opportunities' && $request->hasAny(array_keys(InvestmentGeography::FIELDS))) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
if ($record->exists) {
$current = Opportunity::whereKey($record->id)->lockForUpdate()->firstOrFail();
abort_unless((int) $current->territory_version === (int) $data['territory_version'], 409, 'Localização alterada. Recarregue a ficha.');
}
$path = app(InvestmentGeography::class)->selection($data, true);
if (($data['geography_scope'] ?? null) === 'national' && $path !== []) {
throw ValidationException::withMessages(['geography_scope' => 'O âmbito nacional não pode ter uma localização territorial específica.']);
}
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
if ($path !== [] && empty($data['geography_scope'])) {
$data['geography_scope'] = 'specific';
}
$data['territory_version'] = (int) ($data['territory_version'] ?? 0) + 1;
$data['province'] = $path[0]->name ?? null;
$data['district'] = $path[1]->name ?? null;
} else {
unset($data['territory_version']);
}
PortalAudit::save($record, $data, $request->user()->id);
});
return redirect('/management/'.$resource)->with('success', 'Registo guardado.');
}
@@ -0,0 +1,185 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityContacts;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\PortalInternalDesignation;
use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration;
use App\Services\PortalSuperusers;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PortalProfileController extends Controller
{
public function requests(Request $request, PortalProfileAccess $access): Response
{
return Inertia::render('profiles/request', ['requests' => DB::table('portal_profile_requests')->where('user_id', $request->user()->id)->latest()->get(),
'types' => OpportunityManagement::CATEGORIES,
'organizations' => 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']),
'documents' => DB::table('documents')->where('uploaded_by', $request->user()->id)->whereNull('deleted_at')->where('status', 'active')->where('malware_scan_status', 'clean')
->whereColumn('sha256', 'malware_scanned_sha256')->get(['id', 'file_name']), 'grants' => $access->grants($request->user(), 'promoter')]);
}
public function requestProfile(Request $request, PortalProfileAdministration $service): JsonResponse
{
return response()->json(['id' => $service->request($request->user(), $request->all()), 'message' => 'Pedido recebido; perfil ainda não ativado.'], 201);
}
public function requestStatus(Request $request, string $id): JsonResponse
{
$row = DB::table('portal_profile_requests')->where('id', $id)->where('user_id', $request->user()->id)->first();
abort_unless($row !== null, 403);
return response()->json($row);
}
public function administration(Request $request, PortalProfileAdministration $service): Response
{
$service->administrative($request->user());
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(),
'internalTypes' => OpportunityCanonicalContract::BASE_CATEGORIES,
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
'securityRequests' => DB::table('portal_security_approvals')->latest()->limit(30)->get(),
'securityAlerts' => DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->whereIn('action', ['superuser_pair_initialized', 'superuser_replaced', 'role_revoked'])->latest('occurred_at')->limit(20)->get(['id', 'action', 'actor_user_id', 'occurred_at'])]);
}
public function decide(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
{
$service->decide($request->user(), $id, $request->all());
return response()->json(['message' => 'Decisão registada; somente os tipos aprovados ficam autorizados.']);
}
public function audit(Request $request, PortalProfileAccess $access): Response|JsonResponse
{
abort_unless($access->has($request->user(), 'auditor') || ($access->superuser($request->user()) && $access->mfa($request->user())), 403);
$filters = $request->validate(['actor_id' => 'nullable|integer|min:1', 'action' => 'nullable|string|max:100', 'record_id' => 'nullable|integer|min:1', 'from' => 'nullable|date', 'until' => 'nullable|date|after_or_equal:from', 'format' => 'nullable|in:json']);
$query = DB::table('audit_events')->when($filters['actor_id'] ?? null, fn ($q, $v) => $q->where('actor_user_id', $v))
->when($filters['action'] ?? null, fn ($q, $v) => $q->where('action', $v))
->when($filters['record_id'] ?? null, fn ($q, $v) => $q->where('after_data->record_id', $v))
->when($filters['from'] ?? null, fn ($q, $v) => $q->where('occurred_at', '>=', $v))
->when($filters['until'] ?? null, fn ($q, $v) => $q->where('occurred_at', '<=', $v));
// Minimized metadata: never decrypt private snapshots or export authentication bodies.
$query->select(['id', 'source_system', 'action', 'actor_user_id', 'entity_type', 'occurred_at', 'correlation_id']);
if (($filters['format'] ?? null) === 'json') {
$records = $query->orderByDesc('occurred_at')->limit(1000)->get();
$access->audit($request->user(), 'audit_metadata_exported', ['filters' => $filters, 'count' => $records->count()]);
return response()->json(['filters' => $filters, 'records' => $records, 'limit' => 1000])->header('Content-Disposition', 'attachment; filename="audit-metadata.json"')->header('Cache-Control', 'private, no-store');
}
$records = $query->orderByDesc('occurred_at')->paginate(30)->withQueryString();
$access->audit($request->user(), 'audit_metadata_read', ['filters' => $filters, 'count' => $records->count()]);
return Inertia::render('profiles/audit', ['records' => $records, 'filters' => $filters]);
}
public function registerOperatorOrganization(Request $request, PortalInternalDesignation $service): JsonResponse
{
return response()->json($service->registerOrganization($request->user(), $request->all()), 201);
}
public function grant(Request $request, PortalProfileAdministration $service): JsonResponse
{
return response()->json(['id' => $service->grant($request->user(), $request->all())], 201);
}
public function requestDocument(Request $request, string $id, string $documentId, PortalProfileAdministration $service): \Illuminate\Http\Response|BinaryFileResponse
{
$service->administrative($request->user());
$row = DB::table('portal_profile_requests')->where('id', $id)->first();
abort_unless($row && in_array($documentId, json_decode($row->document_ids, true), true), 404);
$document = DB::table('documents')->where('id', $documentId)->first();
abort_unless($document !== null, 404);
return app(VerifiedDocumentBinary::class)->respond($document, true);
}
public function revoke(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
{
$service->revoke($request->user(), $id, (string) $request->input('reason'));
return response()->json(['message' => 'Papel revogado; sessões e tokens anteriores invalidados.']);
}
public function opportunities(Request $request, PortalProfileAccess $access): Response
{
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
return app(OpportunityManagementController::class)->index($request, app(OpportunityManagement::class));
}
public function edit(Request $request, PortalProfileAccess $access, ?int $id = null): Response|\Symfony\Component\HttpFoundation\Response
{
if ($id) {
$access->authorize($request->user(), str_ends_with($request->path(), '/view') ? 'view' : 'edit', Opportunity::withTrashed()->findOrFail($id));
} else {
if (! ($access->navigation($request->user())['contexts'] ?? [])) {
$reason = $access->has($request->user(), 'auditor')
? 'O papel de Auditor permite consulta e impede a criação de oportunidades.'
: 'Esta conta não dispõe de um contexto ativo de Promotor para uma organização e modalidade autorizadas. Confirme a vigência, o âmbito e os tipos atribuídos na gestão de perfis. A elevação excecional de Superutilizador não substitui a autorização de Promotor.';
if ($request->expectsJson()) {
abort(403, $reason);
}
return response()->view('profiles.creation-unavailable', ['reason' => $reason], 403);
}
}
return app(OpportunityManagementController::class)->edit($request, app(OpportunityManagement::class), $id);
}
public function workflow(Request $request, int $id, OpportunityProfileWorkflow $service): JsonResponse
{
$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.']);
}
public function contact(Request $request, int $id, OpportunityContacts $service): JsonResponse
{
return response()->json($service->save($request->user(), $id, $request->all()));
}
public function replacement(Request $request, PortalSuperusers $service): JsonResponse
{
$data = $request->validate(['old_user_id' => 'required|integer', 'new_user_id' => 'required|integer', 'reason' => 'required|string|min:20|max:1000']);
return response()->json(['id' => $service->requestReplacement($request->user(), $data['old_user_id'], $data['new_user_id'], $data['reason'])], 201);
}
public function approveSecurity(Request $request, string $id, PortalSuperusers $service): JsonResponse
{
$service->approve($request->user(), $id);
return response()->json(['message' => 'Aprovação independente registada.']);
}
public function replace(Request $request, string $id, PortalSuperusers $service): JsonResponse
{
$service->applyReplacement($request->user(), $id);
return response()->json(['message' => 'Titular substituído atomicamente; continuam dois titulares.']);
}
public function elevation(Request $request, PortalSuperusers $service): JsonResponse
{
$data = $request->validate(['action' => 'required|string', 'record_id' => 'nullable|integer|exists:opportunities,id', 'reason' => 'required|string|min:20|max:1000']);
return response()->json(['id' => $service->requestElevation($request->user(), $data['action'], $data['record_id'] ?? null, $data['reason'])], 201);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Services\PortalProfileInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PortalProfileInstallationController extends Controller
{
public function preview(Request $request, PortalProfileInstallation $installation): JsonResponse|Response
{
$plan = $installation->preview($request->user());
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/installation', $plan);
}
public function apply(Request $request, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->apply($request->user(), $request->all()));
}
public function bootstrap(Request $request, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->bootstrap($request->user(), $request->all()));
}
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);
}
public function confirmBootstrap(Request $request, string $id, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->confirmBootstrap($request->user(), $id, $request->all()));
}
}
+726 -45
View File
@@ -2,16 +2,27 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use App\Services\LegacyContent;
use App\Services\PortalProfileAccess;
use App\Services\Malware\MalwareScanner;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -20,24 +31,46 @@ class PortalServicesController extends Controller
{
public function home(): Response
{
return Inertia::render('PortalInvestidor', ['essentialResources' => $this->resources()->where('r.essential', true)
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title'])]);
try {
$essentialResources = $this->resources()->where('r.essential', true)
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title']);
} catch (QueryException $exception) {
// A catalogue schema mismatch must not take down unrelated home content.
// Keep the document list empty and retain the full exception in internal logs.
report($exception);
$essentialResources = collect();
}
return Inertia::render('PortalInvestidor', ['essentialResources' => $essentialResources, 'countries' => app(CountryCatalogue::class)->all()]);
}
private function resources(): Builder
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at');
->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');
}
public function page(Request $request): Response
public function page(Request $request): Response|JsonResponse|RedirectResponse
{
$page = $request->route()->defaults['page'];
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100']]);
$props = ['section' => $page, 'filters' => $filters];
if ($page === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
return app(PartnershipExchangeController::class)->index($request);
}
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100'],
'document_type' => ['nullable', 'string', 'max:40'], 'language' => ['nullable', 'string', 'max:12'],
'issuer' => ['nullable', 'string', 'max:180'], 'legal_status' => ['nullable', 'string', 'max:24'],
'source_confirmation' => ['nullable', 'string', 'max:24'], 'territory_id' => ['nullable', 'uuid'],
'activity' => ['nullable', 'array', 'max:10'], 'activity.*' => ['string', 'max:180'],
'stage' => ['nullable', 'array', 'max:10'], 'stage.*' => ['string', 'max:80'],
'module' => ['nullable', 'array', 'max:10'], 'module.*' => ['string', 'max:80'],
]);
$props = ['section' => $page, 'filters' => $filters, 'countries' => app(CountryCatalogue::class)->all()];
if ($page === 'sectors') {
$props['sectors'] = Sector::where('is_active', true)->withCount(['opportunities' => fn ($q) => $q->published()])->orderBy('sort_order')->get(['id', 'name', 'slug', 'short_description']);
$props['sectors'] = \App\Services\SectorOrganization::catalog();
}
if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources();
@@ -46,15 +79,59 @@ class PortalServicesController extends Controller
}
$props['categories'] = (clone $query)->distinct()->orderBy('r.category')->pluck('r.category');
$props['topics'] = (clone $query)->distinct()->orderBy('r.topic')->pluck('r.topic');
foreach (['category', 'topic'] as $field) {
foreach (['category', 'topic', 'document_type', 'language', 'legal_status', 'source_confirmation'] as $field) {
if (! empty($filters[$field])) {
$query->where('r.'.$field, $filters[$field]);
}
}
if (! empty($filters['q'])) {
$query->where('r.title', 'ilike', '%'.$filters['q'].'%');
if (! empty($filters['issuer'])) {
$query->where('d.issuer', 'ilike', '%'.$filters['issuer'].'%');
}
if (! empty($filters['territory_id'])) {
$territoryIds = $this->territoryScopeIds($filters['territory_id']);
$query->where(function (Builder $territorial) use ($territoryIds): void {
$territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $territoryIds)
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'territory')->whereIn('l.target_key', $territoryIds));
});
}
foreach (['activity', 'stage', 'module'] as $dimension) {
if (! empty($filters[$dimension])) {
$query->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', $dimension)
->whereIn('l.target_key', array_values($filters[$dimension])));
}
}
if (! empty($filters['q'])) {
$term = '%'.$filters['q'].'%';
$query->where(fn (Builder $search) => $search->where('r.title', 'ilike', $term)->orWhere('r.description', 'ilike', $term)
->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)
->orWhere('r.source_reference', 'ilike', $term)->orWhere('r.category', 'ilike', $term)->orWhere('r.topic', 'ilike', $term));
}
$pageResult = $query->orderByRaw("CASE WHEN r.document_type IN ('official_form','digital_adaptation','guidance_template') THEN 1 ELSE 0 END")
->orderByDesc('r.essential')->orderBy('r.title')->select([
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type', 'r.document_number', 'r.document_date',
'r.applicable_from', 'r.applicable_until',
'r.source_url', 'r.language', 'r.version_label', 'r.document_group_id', 'r.territorial_scope', 'r.territory_id',
'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(\App\Services\PublicDocumentPresentation::class)->resource($resource));
$props['resources'] = $pageResult;
$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');
$props['legalStatuses'] = (clone $this->resources())->distinct()->orderBy('r.legal_status')->pluck('r.legal_status');
$props['territories'] = DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
->whereIn('location_type', ['province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']);
foreach (['activities' => 'activity', 'stages' => 'stage', 'modules' => 'module'] as $key => $type) {
$props[$key] = DB::table('portal_resource_links as l')->join('portal_resources as r', 'r.id', '=', 'l.resource_id')
->join('documents as d', 'd.id', '=', 'r.document_id')->where('l.target_type', $type)->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')
->distinct()->orderBy('l.target_key')->pluck('l.target_key');
}
$props['resources'] = $query->orderByDesc('r.essential')->orderBy('r.title')->select(['r.id', 'r.title', 'r.category', 'r.topic', 'r.source_reference', 'r.essential'])->paginate(20)->withQueryString();
}
if ($page === 'partnerships') {
$query = DB::table('portal_partnerships')->where('status', 'published');
@@ -69,6 +146,9 @@ class PortalServicesController extends Controller
$props['partnerships'] = $query->orderByDesc('id')->select(['id', 'title', 'sector', 'province', 'offer', 'seeking'])->paginate(20)->withQueryString();
}
if ($page === 'desk') {
if (app(VirtualDesk::class)->installed()) {
return redirect('/balcao-virtual/painel');
}
$props['requests'] = InterestRequest::where('metadata->portal_user_id', $request->user()->id)->latest()->limit(100)->get(['reference', 'status', 'created_at', 'metadata']);
$props['myPartnerships'] = DB::table('portal_partnerships')->where('user_id', $request->user()->id)->latest()->limit(100)->get(['id', 'title', 'status']);
}
@@ -77,12 +157,49 @@ class PortalServicesController extends Controller
return Inertia::render('portal/services', $props);
}
public function recommendations(Request $request): JsonResponse
{
$data = $request->validate([
'activity_id' => ['nullable', 'string', 'max:180'], 'province_id' => ['nullable', 'uuid'],
'stage' => ['nullable', 'string', 'max:80'], 'module' => ['nullable', 'string', 'max:80'],
]);
$module = $data['module'] ?? 'simulator';
$query = $this->resources()->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'module')->where('l.target_key', $module));
$matches = [['type' => 'module', 'key' => $module]];
foreach (['activity_id' => 'activity', 'stage' => 'stage'] as $input => $type) {
if (! empty($data[$input])) {
$key = $data[$input];
$query->where(function (Builder $contextual) use ($type, $key): void {
$contextual->whereNotExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as general')
->whereColumn('general.resource_id', 'r.id')->where('general.target_type', $type))
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as matching')
->whereColumn('matching.resource_id', 'r.id')->where('matching.target_type', $type)->where('matching.target_key', $key));
});
$matches[] = ['type' => $type, 'key' => $data[$input]];
}
}
if (! empty($data['province_id'])) {
$ids = $this->territoryScopeIds($data['province_id']);
$query->where(fn (Builder $territorial) => $territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $ids)
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')->whereColumn('l.resource_id', 'r.id')
->where('l.target_type', 'territory')->whereIn('l.target_key', $ids)));
$matches = [...$matches, ...array_map(fn ($id) => ['type' => 'territory', 'key' => $id], $ids)];
}
$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(\App\Services\PublicDocumentPresentation::class)->resource($resource));
return response()->json(['data' => $resources]);
}
public function submit(Request $request, InterestSubmission $submission): RedirectResponse
{
$data = $request->validate([
'kind' => ['required', 'in:support,investor,company,partnership'],
'fullName' => ['required', 'string', 'min:3', 'max:150'], 'email' => ['required', 'email:rfc', 'max:150'],
'phone' => ['nullable', 'string', 'max:30'], 'country' => ['required', 'string', 'max:100'],
'phone' => ['nullable', 'string', 'max:30'], 'country' => ['required', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'declared_origin_country_code' => ['nullable', app(CountryCatalogue::class)->rule()],
'company' => ['nullable', 'required_if:kind,company', 'string', 'max:150'],
'sectorInterest' => ['required', 'string', 'max:150'], 'message' => ['required', 'string', 'min:20', 'max:2000'],
'privacyConsent' => ['required', 'accepted'], 'submission_key' => ['required', 'uuid'],
@@ -92,15 +209,25 @@ class PortalServicesController extends Controller
abort_unless(DB::table('portal_partnerships')->where('id', $data['partnership_id'])->where('status', 'published')->exists(), 422);
}
$data['portal_user_id'] = $request->user()?->id;
// Preserve the actual entry form, not only the shared POST endpoint. Never accept an external or administrative referrer as the entry page.
$entry = parse_url($request->headers->get('referer', ''));
$sourcePage = '/apoio';
if (is_array($entry) && strtolower($entry['scheme'] ?? '') === $request->getScheme()
&& strtolower($entry['host'] ?? '') === strtolower($request->getHost())
&& (int) ($entry['port'] ?? (($entry['scheme'] ?? '') === 'https' ? 443 : 80)) === $request->getPort()
&& in_array($entry['path'] ?? '', ['/apoio', '/formularios/investidor', '/formularios/empresa', '/parcerias', '/parcerias/painel'], true)) {
$sourcePage = $entry['path'];
}
try {
$interest = $submission->receive($data, $data['submission_key'], fn () => InterestRequest::create([
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $data['fullName'], 'email' => $data['email'],
'phone' => $data['phone'] ?? null, 'country' => $data['country'],
'investor_type' => $data['kind'] === 'company' ? 'empresa' : 'individual',
'sector_interest' => $data['sectorInterest'], 'message' => $data['message'],
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/apoio',
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => $sourcePage,
'metadata' => ['kind' => $data['kind'], 'company' => $data['company'] ?? null,
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null],
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null,
'declared_origin_country_code' => $data['declared_origin_country_code'] ?? null],
]));
} catch (IdempotencyConflict) {
abort(409, 'Este envio já foi usado com dados diferentes. Recarregue o formulário.');
@@ -120,82 +247,636 @@ class PortalServicesController extends Controller
return back()->with('success', 'Proposta recebida para publicação. Acompanhe no Balcão Virtual.');
}
public function download(int $id): BinaryFileResponse
public function download(int $id): BinaryFileResponse|\Illuminate\Http\Response
{
$resource = $this->resources()->where('r.id', $id)->first(['d.storage_key', 'd.file_name', 'd.sha256']);
$resource = $this->resources()->where('r.id', $id)->first(['d.*']);
abort_unless($resource !== null, 404);
$root = realpath(Storage::disk('local')->path(''));
$path = realpath(Storage::disk('local')->path($resource->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
$checksum = hash_file('sha256', $path);
abort_unless($checksum !== false && hash_equals($resource->sha256, $checksum), 404);
return response()->download($path, basename($resource->file_name), ['X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store']);
return app(VerifiedDocumentBinary::class)->respond($resource);
}
public function manage(): Response
public function showDocument(Request $request, int $id): Response
{
return Inertia::render('portal/manage', ['resources' => DB::table('portal_resources')->latest()->limit(100)->get(),
$resource = $this->resources()->where('r.id', $id)->first([
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type',
'r.document_number', 'r.document_date', 'r.applicable_from', 'r.applicable_until', 'r.source_url', 'r.language', 'r.version_label',
'r.territorial_scope', 'r.territory_id', 'r.source_confirmation', 'r.legal_status', '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',
]);
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(\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';
return Inertia::render('portal/document', [
'resource' => $resource,
'backUrl' => $backUrl,
'downloadUrl' => route('services.download', ['id' => $id]),
'articleUrl' => $this->articleUrl($id),
]);
}
public function viewDocument(int $id): BinaryFileResponse|\Illuminate\Http\Response
{
$document = $this->resources()->where('r.id', $id)->first(['d.*']);
abort_unless($document && in_array($document->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true), 404);
return app(VerifiedDocumentBinary::class)->respond($document, true);
}
public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response
{
$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);
return app(VerifiedDocumentBinary::class)->respond($document);
}
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(\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')
->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');
$resources->each(function ($resource) use ($links, $revisions) {
$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(),
'documents' => (clone $documents)->latest()->limit(250)->get([
'id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until',
'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256',
'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason',
'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts',
]),
'malwareCounts' => (clone $documents)->select('malware_scan_status', DB::raw('count(*) as total'))
->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
'malwareHealth' => $health,
]);
}
$disclosureAuthorization = DB::table('audit_events')->where('source_system', 'user-instruction')
->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)
->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());
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
$malwareHealth = $scanner->health();
$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(),
'requirementAssociationsEnabled' => $this->requirementEvidenceEnabled(),
'partnerships' => DB::table('portal_partnerships')->latest()->limit(100)->get(),
'documents' => DB::table('documents')->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->get(['id', 'file_name'])]);
'documents' => DB::table('documents')->where('status', '!=', 'deleted')->whereNull('deleted_at')->latest()->limit(250)
->get(['id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until', 'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256', 'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason', 'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts']),
'malwareHealth' => $malwareHealth,
'disclosureAuthorization' => $disclosureAuthorizationData ? [
'external_id' => $disclosureAuthorization->external_id,
'occurred_at' => $disclosureAuthorization->occurred_at,
'resources' => DB::table('portal_resources')->whereIn('id', $disclosureAuthorizationData['resource_ids'] ?? [])
->orderBy('id')->get(['id', 'title', 'published', 'rights_status']),
] : null,
'malwareCounts' => DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted')
->select('malware_scan_status', DB::raw('count(*) as total'))->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
'territories' => DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
->whereIn('location_type', ['country', 'province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type']),
'incompleteCount' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where(fn ($q) => $q->whereNull('r.description')->orWhereNull('d.issuer')->orWhereNull('r.source_url')
->orWhere('r.source_confirmation', 'unconfirmed')->orWhere('r.legal_status', 'unknown')->orWhere('r.rights_status', 'unconfirmed'))
->count(),
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
}
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;
}
private function contentAssociations(Request $request): array
{
$access = app(\App\Services\DocumentManagementAccess::class);
$resources = $access->full($request->user()) ? DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
: ($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' => \App\Models\Sector::choices()->orderBy('name')->get(['id as value', 'name as label']),
'territory' => app(\App\Services\InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id as value', 'name as label']),
'opportunity' => app(PortalProfileAccess::class)->scope(\App\Models\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('virtual-desk.steps', []))->map(fn ($step, $key) => ['value' => $key, 'label' => $step['title']])->values(),
'activity' => app(\App\Services\EconomicReferenceCatalog::class)->activities()->map(fn ($row) => ['value' => $row->id, 'label' => $row->display_label])->values(),
];
}
public function upload(Request $request): RedirectResponse
{
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240'], 'publication_consent' => ['accepted']]);
$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'],
'original_source' => ['nullable', 'url:https', 'max:2000'], 'issuer' => ['nullable', 'string', 'max:180']]);
$file = $request->file('file');
$checksum = hash_file('sha256', $file->getRealPath());
if (DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists()) {
return to_route('services.manage')->withErrors(['file' => 'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.']);
}
$id = (string) Str::uuid();
$key = $file->storeAs('portal-documents', $id, 'local');
abort_unless($key !== false, 500);
try {
DB::transaction(function () use ($file, $id, $key, $request) {
DB::transaction(function () use ($file, $id, $key, $request, $checksum) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['investmoz-document-content:'.$checksum]);
abort_unless(! DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists(), 409,
'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.');
DB::table('documents')->insert(['id' => $id, 'source_system' => 'portal-services',
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => hash_file('sha256', Storage::disk('local')->path($key)), 'status' => 'active', 'data_classification' => 'public']);
'sha256' => $checksum, 'size_bytes' => $file->getSize(), 'issuer' => $request->input('issuer'),
'original_source' => $request->input('original_source'), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'internal', 'rights_status' => 'unconfirmed']);
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => 'documents', 'entity_id' => $id,
'actor_user_id' => $request->user()->id, 'action' => 'public_document_uploaded']);
'actor_user_id' => $request->user()->id, 'action' => 'document_uploaded_to_quarantine']);
});
} catch (\Throwable $exception) {
Storage::disk('local')->delete($key);
throw $exception;
}
return back()->with('success', 'Documento carregado. Seleccione-o no catálogo e registe a fonte para publicar.');
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.');
}
public function resource(Request $request): RedirectResponse
{
$data = $request->validate(['document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'category' => ['required', 'string', 'max:80'], 'topic' => ['required', 'string', 'max:80'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'], 'published' => ['required', 'boolean']]);
DB::transaction(function () use ($data, $request) {
abort_unless(DB::table('documents')->where('id', $data['document_id'])->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->lockForUpdate()->exists(), 422, 'Seleccione um documento público activo.');
$id = DB::table('portal_resources')->insertGetId([...$data, 'approved_by' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($request, 'resource_registered', 'portal_resources', $id);
$data = $this->validateResource($request);
$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(\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]);
}
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
$this->assertPublicClassificationEligible($data, $document);
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
$this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'created', $request->user()->id, 1);
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
$this->audit($request, $duplicate ? 'resource_registered_possible_duplicate' : 'resource_registered', 'portal_resources', $id);
return $id;
});
return back()->with('success', 'Documento catalogado.');
return to_route('services.manage')->with('success', 'Documento registado como rascunho privado da biblioteca. A publicação requer uma acção separada.');
}
public function updateResource(Request $request, int $id): RedirectResponse
{
$data = $this->validateResource($request);
$expectedVersion = (int) $request->validate(['metadata_version' => ['required', 'integer', 'min:1']])['metadata_version'];
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(\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(\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]);
}
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
$this->assertPublicClassificationEligible($data, $document);
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
&& $document->status === 'active' && ($data['data_classification'] ?? $document->data_classification) === 'public' && $document->rights_status === 'authorized'
&& $document->malware_scan_status === 'clean' && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256));
DB::table('portal_resources')->where('id', $id)->update([...$fields, 'approved_by' => $retainPublication ? $resource->approved_by : null, 'published' => $retainPublication,
'metadata_version' => $expectedVersion + 1, 'updated_at' => now()]);
DB::table('portal_resource_links')->where('resource_id', $id)->delete();
$this->syncResourceLinks($id, $data['associations'] ?? []);
$this->recordResourceRevision($id, 'metadata_updated', $request->user()->id, $expectedVersion + 1);
DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer,
'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'],
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
});
return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
}
public function moderate(Request $request, string $type, int $id): RedirectResponse
{
$data = $request->validate(['published' => ['required', 'boolean']]);
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
abort_unless($type === 'resources', 403);
$access->assertEditableResource($request->user(), $id);
}
if ($type === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
}
$data = $this->validateManagement($request, ['published' => ['required', 'boolean']]);
$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(\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',
'r.source_url', 'd.status', 'd.data_classification', 'd.deleted_at', 'd.rights_status as file_rights_status',
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.sha256']);
abort_unless($resource !== null && $resource->deleted_at === null, 404);
if ($resource->status !== 'active') {
throw $this->managementValidationException(['published' => 'A análise técnica ainda não libertou o ficheiro da quarentena. Aguarde a análise antimalware antes de publicar.']);
}
if ($resource->data_classification !== 'public') {
throw $this->managementValidationException(['published' => 'O ficheiro já saiu da quarentena técnica, mas continua classificado como interno. Reveja os direitos e a possibilidade de divulgação; só então altere explicitamente a classificação para pública.']);
}
if ($resource->rights_status !== 'authorized' || $resource->file_rights_status !== 'authorized') {
throw $this->managementValidationException(['published' => 'Confirme e registe os direitos de utilização antes de publicar.']);
}
if ($resource->source_confirmation === 'unconfirmed' || ! $resource->source_url) {
throw $this->managementValidationException(['published' => 'Identifique e confirme a fonte documental antes de publicar.']);
}
if ($resource->malware_scan_status !== 'clean'
|| ! hash_equals(trim($resource->sha256), trim((string) $resource->malware_scanned_sha256))) {
throw $this->managementValidationException(['published' => 'A análise antimalware tem de terminar antes da publicação.']);
}
}
DB::table($table)->where('id', $id)->update($type === 'resources'
? ['published' => $data['published'], 'approved_by' => $request->user()->id, 'updated_at' => now()]
? ['published' => $data['published'], 'approved_by' => $data['published'] ? $request->user()->id : null,
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'metadata_version' => DB::raw('metadata_version + 1'), 'updated_at' => now()]
: ['status' => $data['published'] ? 'published' : 'withdrawn', 'reviewed_by' => $request->user()->id, 'updated_at' => now()]);
if ($type === 'resources') {
$this->recordResourceRevision($id, $data['published'] ? 'published' : 'unpublished', $request->user()->id,
(int) DB::table('portal_resources')->where('id', $id)->value('metadata_version'));
}
$this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id);
});
return back()->with('success', 'Publicação actualizada.');
return to_route('services.manage')->with('success', 'Publicação actualizada.');
}
private function audit(Request $request, string $action, string $table, int $id): void
public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse
{
$event = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first();
abort_unless($event !== null, 404);
$authorization = json_decode($event->after_data, true);
$resourceIds = array_values(array_unique(array_map('intval', $authorization['resource_ids'] ?? [])));
abort_unless(count($resourceIds) === 4, 409, 'O âmbito da autorização registada foi alterado; interrompida a publicação.');
$published = [];
$skipped = [];
DB::transaction(function () use ($request, $event, $resourceIds, &$published, &$skipped): void {
foreach ($resourceIds as $resourceId) {
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.id', $resourceId)->lockForUpdate()->first([
'r.id', 'r.title', 'r.document_type', 'r.legislation', 'r.source_url', 'r.source_confirmation',
'r.rights_status as resource_rights', 'r.published', 'r.approved_by', 'r.metadata_version',
'd.id as document_id', 'd.storage_key', 'd.sha256', 'd.status', 'd.deleted_at', 'd.rights_status as file_rights',
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.data_classification',
]);
if ($resource === null) {
$skipped[$resourceId] = 'Registo ou ficheiro inexistente.';
continue;
}
if ($resource->published && $resource->data_classification === 'public' && $resource->resource_rights === 'authorized' && $resource->file_rights === 'authorized') {
$published[$resourceId] = 'Já publicado.';
continue;
}
$path = Storage::disk('local')->path($resource->storage_key);
$root = realpath(Storage::disk('local')->path(''));
$realPath = realpath($path);
$actualHash = $realPath && is_file($realPath) && $root
&& str_starts_with(strtolower($realPath), strtolower($root.DIRECTORY_SEPARATOR))
? hash_file('sha256', $realPath) : false;
if ($resource->deleted_at !== null || $resource->status !== 'active'
|| $resource->malware_scan_status !== 'clean' || ! is_string($actualHash)
|| ! hash_equals((string) $resource->sha256, $actualHash)
|| ! hash_equals((string) $resource->sha256, (string) $resource->malware_scanned_sha256)) {
$skipped[$resourceId] = 'Ficheiro indisponível, não limpo ou diferente dos bytes analisados.';
continue;
}
if (in_array($resource->resource_rights, ['restricted'], true) || in_array($resource->file_rights, ['restricted'], true)
|| $resource->source_confirmation === 'unconfirmed' || ! filter_var($resource->source_url, FILTER_VALIDATE_URL)
|| parse_url($resource->source_url, PHP_URL_SCHEME) !== 'https') {
$skipped[$resourceId] = 'Restrição de divulgação ou origem identificada em falta.';
continue;
}
$now = now();
$nextVersion = (int) $resource->metadata_version + 1;
DB::table('documents')->where('id', $resource->document_id)->update([
'rights_status' => 'authorized', 'data_classification' => 'public', 'updated_at' => $now,
]);
DB::table('portal_resources')->where('id', $resourceId)->update([
'rights_status' => 'authorized', 'published' => true, 'approved_by' => $request->user()->id,
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => $now,
'metadata_version' => $nextVersion, 'updated_at' => $now,
]);
$this->attachAuthorizedDocumentaryEvidence($resourceId, $resource->title);
$this->recordResourceRevision($resourceId, 'published', $request->user()->id, $nextVersion);
$this->audit($request, 'published_under_user_authorization', 'portal_resources', $resourceId, [
'authorization_source_system' => $event->source_system,
'authorization_external_id' => $event->external_id,
'source_confirmation' => $resource->source_confirmation,
'legal_status_preserved' => true,
]);
$published[$resourceId] = 'Publicado com a situação de fonte e vigência já registada.';
}
});
return to_route('services.manage')->with('success', count($published).' documento(s) disponibilizado(s); '.count($skipped).' pendência(s) individual(is).')
->with('disclosureResults', ['published' => $published, 'skipped' => $skipped]);
}
/** @param array<string,mixed> $additional */
private function audit(Request $request, string $action, string $table, int $id, array $additional = []): void
{
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => $table,
'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['record_id' => $id], JSON_THROW_ON_ERROR)]);
'actor_user_id' => $request->user()->id, 'action' => $action,
'after_data' => json_encode(['record_id' => $id, ...$additional], JSON_THROW_ON_ERROR)]);
}
private function attachAuthorizedDocumentaryEvidence(int $resourceId, string $title): void
{
if (! $this->requirementEvidenceEnabled()) {
return;
}
$evidence = match (true) {
str_contains($title, 'Lei n.º 22/2022') => [['fiscal.iva.lei_22_2022', 'confirmed', 'Lei n.º 22/2022, art. 17(1); citação conferida no exemplar cujo SHA-256 coincide com o ficheiro oficial. Isto é evidência documental, não valida vigência ou cálculo.']],
str_contains($title, 'BR 248/2025') || str_contains($title, 'n.º 248/2025') => array_map(fn (array $law) => ['fiscal.'.$law[0].'.'.$law[1], 'pending', 'BR n.º 248/2025, Suplemento n.º 1, '.$law[2].'; artigo/dispositivo específico não conferido nesta associação. Evidência identificada, não fundamento confirmado.'], [
['ice', 'lei_7_2025', 'Lei n.º 7/2025'], ['customs', 'lei_8_2025', 'Lei n.º 8/2025'],
['ispc', 'lei_9_2025', 'Lei n.º 9/2025'], ['iva', 'lei_10_2025', 'Lei n.º 10/2025'],
['irps', 'lei_11_2025', 'Lei n.º 11/2025'], ['irpc', 'lei_12_2025', 'Lei n.º 12/2025'],
]),
str_contains($title, 'Lei n.º 13/2016') || str_contains($title, 'BR n.º 89') => [['fiscal.iva.lei_13_2016_rectification_2017', 'pending', 'BR n.º 89/2017, I Série, p. 569, aviso relativo à Lei n.º 13/2016; a correspondência do exemplar local e os dispositivos rectificados aguardam conferência. Evidência identificada, não fundamento confirmado.']],
default => [],
};
foreach ($evidence as [$key, $reviewStatus, $basis]) {
if (! array_key_exists($key, config('document-requirements.catalogue', []))) {
continue;
}
$exists = DB::table('portal_resource_links')->where('resource_id', $resourceId)
->where('target_type', 'requirement')->where('target_key', $key)->exists();
if ($exists) {
continue;
}
DB::table('portal_resource_links')->insert([
'resource_id' => $resourceId, 'target_type' => 'requirement', 'target_key' => $key,
'relationship' => 'cites', 'applicability' => 'evidence', 'basis_reference' => $basis,
'review_status' => $reviewStatus, 'created_at' => now(), 'updated_at' => now(),
]);
}
}
/** @return array<string,mixed> */
private function validateResource(Request $request): array
{
$data = $this->validateManagement($request, [
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'],
// QA legacy originals may have no theme (e.g. a shared image). Do not invent a sector to disclose an authorized original.
'topic' => [app(LegacyContent::class)->available() ? 'nullable' : 'required', 'string', 'max:80'],
'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
'issuer' => ['nullable', 'string', 'max:180'], 'document_number' => ['nullable', 'string', 'max:120'],
'document_date' => ['nullable', 'date_format:Y-m-d'], 'source_url' => ['nullable', 'url:https', 'max:2000'],
'applicable_from' => ['nullable', 'date_format:Y-m-d'],
'applicable_until' => ['nullable', 'date_format:Y-m-d'],
'language' => ['required', 'string', 'max:12'], 'version_label' => ['nullable', 'string', 'max:120'],
'document_group_id' => ['nullable', 'uuid'], 'territorial_scope' => ['required', 'in:national,provincial,district,locality,multiple,unknown'],
'territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
'source_confirmation' => ['required', 'in:unconfirmed,identified,official_confirmed'],
'legal_status' => ['required', 'in:not_applicable,in_force,historical,proposed,repealed,amended,unknown'],
'rights_status' => ['required', 'in:unconfirmed,authorized,restricted'], 'independent_review' => ['required', 'boolean'],
'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.*.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'],
'associations.*.applicability' => ['required', 'in:required,conditional,support,optional_template,evidence'],
'associations.*.condition_text' => ['nullable', 'string', 'max:2000'], 'associations.*.basis_reference' => ['nullable', 'string', 'max:2000'],
]);
$data['topic'] = $data['topic'] ?? '';
if (! empty($data['applicable_from']) && ! empty($data['applicable_until']) && $data['applicable_until'] < $data['applicable_from']) {
throw $this->managementValidationException(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']);
}
foreach ($data['associations'] ?? [] as $index => $association) {
if (($association['target_type'] ?? null) === 'requirement' && ! $this->requirementEvidenceEnabled()) {
throw $this->managementValidationException(["associations.$index.target_type" => 'As associações documentais a requisitos ainda aguardam a migração do catálogo.']);
}
if (($association['target_type'] ?? null) === 'requirement'
&& ! array_key_exists($association['target_key'], config('document-requirements.catalogue', []))) {
throw $this->managementValidationException(["associations.$index.target_key" => 'Seleccione um identificador técnico de requisito existente.']);
}
if (mb_strlen(trim($association['basis_reference'] ?? '')) < 5) {
throw $this->managementValidationException(["associations.$index.basis_reference" => 'Indique o artigo, secção, página ou outro trecho que esta associação sustenta.']);
}
if ($association['applicability'] === 'conditional' && mb_strlen(trim($association['condition_text'] ?? '')) < 5) {
throw $this->managementValidationException(["associations.$index.condition_text" => 'Descreva a condição de aplicação.']);
}
}
return $data;
}
/** Keep validation redirects on the management GET page even if the request
* arrived with a stale Referer pointing at a write-only resource endpoint.
*
* @param array<string,mixed> $rules
* @return array<string,mixed>
*/
private function validateManagement(Request $request, array $rules): array
{
try {
return $request->validate($rules);
} catch (ValidationException $exception) {
throw $this->managementValidationException($exception->errors());
}
}
/** @param array<string,string|list<string>> $errors */
private function managementValidationException(array $errors): ValidationException
{
$exception = ValidationException::withMessages($errors);
$exception->redirectTo = route('services.manage');
return $exception;
}
/** @param array<string,mixed> $data
* @return array<string,mixed> */
private function resourceFields(array $data, int $actor, ?string $identity, ?int $duplicate, bool $creating): array
{
unset($data['associations'], $data['issuer'], $data['data_classification']);
$data['document_identity_key'] = $identity;
$data['duplicate_of_resource_id'] = $duplicate;
$data['last_reviewed_at'] = $data['source_confirmation'] !== 'unconfirmed' || $data['rights_status'] === 'authorized' ? now() : null;
$data['last_reviewed_by'] = $actor;
if ($creating) {
$data['created_by'] = $actor;
} else {
unset($data['metadata_version']);
}
return $data;
}
/** @param list<array<string,mixed>> $links */
private function syncResourceLinks(int $resourceId, array $links): void
{
foreach ($links as $link) {
if ($link['target_type'] === 'document') {
abort_unless(DB::table('portal_resources')->where('id', $link['target_key'])->exists(), 422, 'A relação documental seleccionada não existe.');
}
DB::table('portal_resource_links')->insert([
'resource_id' => $resourceId, 'target_type' => $link['target_type'], 'target_key' => $link['target_key'],
'relationship' => $link['relationship'], 'applicability' => $link['applicability'],
'condition_text' => $link['condition_text'] ?? null, 'basis_reference' => $link['basis_reference'] ?? null,
...($this->requirementEvidenceEnabled() ? ['review_status' => $link['review_status'] ?? 'pending'] : []),
'created_at' => now(), 'updated_at' => now(),
]);
}
}
/** @param array<string,mixed> $data */
private function assertPublicClassificationEligible(array $data, \stdClass $document): void
{
if (($data['data_classification'] ?? $document->data_classification) !== 'public') {
return;
}
abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 422,
'A análise antimalware e a integridade do ficheiro têm de estar confirmadas antes de o classificar como público.');
abort_unless($data['rights_status'] === 'authorized', 422,
'Registe a autorização de utilização antes de classificar o ficheiro como público.');
abort_unless($data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url']), 422,
'Identifique e confirme a fonte antes de classificar o ficheiro como público.');
}
/** @param array<string,mixed> $data */
private function identityKey(array $data): ?string
{
if (empty($data['issuer']) || empty($data['document_number'])) {
return null;
}
return hash('sha256', mb_strtolower(implode('|', [trim($data['issuer']), trim($data['document_number']),
$data['document_date'] ?? '', $data['language'], $data['version_label'] ?? ''])));
}
private function recordResourceRevision(int $id, string $changeType, int $actor, int $revision): void
{
$resource = DB::table('portal_resources')->where('id', $id)->first();
$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' => $changeType,
'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR),
'actor_user_id' => $actor, 'created_at' => now()]);
}
private function requirementEvidenceEnabled(): bool
{
return Schema::hasColumn('portal_resource_links', 'review_status')
&& Schema::hasTable('migrations')
&& 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
{
$rows = DB::select(<<<'SQL'
WITH RECURSIVE territory_tree AS (
SELECT id FROM geo_locations WHERE id = ? AND deleted_at IS NULL AND data_classification = 'public'
UNION ALL
SELECT child.id FROM geo_locations child
INNER JOIN territory_tree parent ON child.parent_id = parent.id
WHERE child.deleted_at IS NULL AND child.data_classification = 'public'
)
SELECT id FROM territory_tree
SQL, [$id]);
$ids = [];
foreach ($rows as $row) {
if (! is_string($row->id)) {
throw new \RuntimeException('Territory identifier must be a string.');
}
$ids[] = $row->id;
}
return $ids ?: [$id];
}
private function articleUrl(int $id): ?string
{
if (!Schema::hasColumn('portal_resources', 'editorial_content')) return null;
$resource = DB::table('portal_resources')->find($id);
if (!$resource) return null;
if ($resource->editorial_content !== null) return '/artigos/'.$id;
try {
return app(\App\Services\EditorialReading::class)->original($resource) ? '/artigos/'.$resource->id : null;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException) {
return null;
}
}
}
@@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers;
use App\Services\Procurement;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class ProcurementController extends Controller
{
private const STATE_SQL = "CASE WHEN v.payload->>'procedural_state' IN ('cancelled','results') THEN v.payload->>'procedural_state' WHEN ((v.payload->>'deadline')::timestamp AT TIME ZONE (v.payload->>'timezone'))<=CURRENT_TIMESTAMP THEN 'deadline_ended' WHEN v.payload->>'extended'='true' THEN 'extended' ELSE 'open' END";
private function query(Procurement $service)
{
abort_unless($service->available(), 404);
return DB::table('portal_tenders as t')->join('portal_tender_versions as v', fn ($q) => $q->on('v.id', '=', 't.published_version_id')->on('v.tender_id', '=', 't.id'))->whereNotNull('v.published_at');
}
public function index(Request $request, Procurement $service)
{
$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 = $this->query($service);
if (! empty($filters['q'])) {
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'object' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'reference' ILIKE ?", ['%'.$filters['q'].'%']));
}
foreach (['entity' => 'organization_id', 'sector' => 'sector_id', 'modality' => 'modality', 'financier' => 'financier', 'regime' => 'regime'] as $filter => $field) {
if (! empty($filters[$filter])) {
$query->whereRaw('v.payload->>? = ?', [$field, (string) $filters[$filter]]);
}
}
if (! empty($filters['territory'])) {
$query->whereRaw("v.payload->>'territory_id' IN (WITH RECURSIVE scope(id,depth) AS (SELECT id,0 FROM geo_locations WHERE id=? AND deleted_at IS NULL UNION ALL SELECT c.id,s.depth+1 FROM geo_locations c JOIN scope s ON c.parent_id=s.id WHERE c.deleted_at IS NULL AND s.depth<4) SELECT id::text FROM scope)", [$filters['territory']]);
}
if (! empty($filters['state'])) {
$query->whereRaw(self::STATE_SQL.' = ?', [$filters['state']]);
}
if (! empty($filters['deadline_before'])) {
$query->whereRaw("left(v.payload->>'deadline',10)<=?", [$filters['deadline_before']]);
}
$rows = $query->select('t.id', 'v.id as vid')->orderByDesc('v.published_at')->paginate(20)->withQueryString();
$rows->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 Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
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)
{
return $this->detail($request, $id, $service, false);
}
public function print(Request $request, string $id, Procurement $service)
{
return $this->detail($request, $id, $service, true);
}
private function detail(Request $request, string $id, Procurement $service, bool $print)
{
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['t.id', 'v.id as vid']);
$filters = $request->only(['q', 'entity', 'sector', 'territory', 'modality', 'financier', 'state', 'deadline_before', 'regime', 'page']);
return Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''),
'history' => DB::table('portal_tender_versions')->where('tender_id', $id)->whereNotNull('published_at')->orderBy('version')->pluck('id')->map(fn ($vid) => $service->publishedVersion($vid)),
'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
public function manage(Request $request, Procurement $service)
{
$service->guard($request->user());
return Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
public function edit(Request $request, Procurement $service, ?string $id = null)
{
$service->guard($request->user());
$r = $id ? $service->record($id) : null;
return Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null,
'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, Procurement $service, ?string $id = null)
{
$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)
{
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)
{
$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)
{
$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)
{
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)
{
$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 Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\PublicBusinessOpportunityResource;
use App\Models\ExistingBusiness;
use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessClassification;
use App\Services\OpportunityClassification;
use App\Services\InvestmentGeography;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class PublicBusinessOpportunityController extends Controller
{
public function index(Request $request): Response|JsonResponse
{
$filters = $this->filters($request);
$query = $this->publishedQuery();
app(ExistingBusinessClassification::class)->constrain($query, $filters);
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->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']);
$query->where(fn (Builder $builder) => $builder->whereLike('existing_businesses.public_title', '%'.$term.'%')
->orWhereLike('existing_businesses.activity_code', '%'.$term.'%')
->orWhereLike('existing_businesses.objective', '%'.$term.'%'));
}
if (! empty($filters['sector'])) {
$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']);
}
$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,
'classificationOptions' => app(OpportunityClassification::class)->options(),
'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz'])]);
}
private function filters(Request $request): array
{
return $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'modality' => ['nullable', Rule::in(ExistingBusinessDrafts::MODALITIES)],
'sector' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'],
'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
...app(ExistingBusinessClassification::class)->filterRules(),
]);
}
public function show(Request $request, string $slug): Response|JsonResponse
{
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
$resource = new PublicBusinessOpportunityResource($record);
if ($request->expectsJson()) {
return $resource->response();
}
$returnFilters = $this->filters($request);
return Inertia::render('investment/business-opportunity', ['record' => $resource->resolve($request),
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]);
}
public function interest(Request $request, string $slug): Response
{
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
$returnFilters = $this->filters($request);
return Inertia::render('investment/business-interest', [
'record' => (new PublicBusinessOpportunityResource($record))->resolve($request),
'modalities' => ExistingBusinessDrafts::MODALITIES,
'countries' => app(CountryCatalogue::class)->all(),
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
]);
}
/** @return Builder<ExistingBusiness> */
private function publishedQuery(): Builder
{
$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('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')
->leftJoinSub($publicLocations, 'public_location', 'public_location.existing_business_id', '=', 'existing_businesses.id')
->select(['existing_businesses.id', 'existing_businesses.public_slug', 'existing_businesses.public_title', 'existing_businesses.modality',
'existing_businesses.activity_code', 'existing_businesses.stage', 'existing_businesses.objective',
'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']);
}
}
@@ -5,22 +5,35 @@ namespace App\Http\Controllers;
use App\Services\InvestmentGeography;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class PublicGeographyController extends Controller
{
public function options(Request $request, InvestmentGeography $geography): JsonResponse
{
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$data = $request->validate(['corridor' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(config('investment_corridors')))], 'type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = $geography->publicReferences()->where('location_type', $data['type']);
if (!empty($data['corridor'])) {
$corridors = app(\App\Services\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']);
}
if ($data['type'] !== 'province') {
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']];
$parent = $geography->publicReferences()->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->where('location_type', $parentType)->value('id');
$query->where('parent_id', $parent ?? '00000000-0000-0000-0000-000000000000');
}
if (! empty($data['q'])) {
$query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
$term = '%'.addcslashes(Str::ascii($data['q']), '%_\\').'%';
$query->where(fn ($q) => $q
->whereRaw("translate(lower(name), 'áàâãäåéèêëíìîïóòôõöúùûüçýÿ', 'aaaaaaeeeeiiiiooooouuuucyy') ILIKE ?", [$term])
->orWhereRaw('code ILIKE ?', [$term])
->orWhereRaw('source_code ILIKE ?', [$term]));
}
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type']));
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code'])->withQueryString());
}
}
@@ -4,6 +4,8 @@ namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\CountryCatalogue;
use App\Services\SectorInvestment;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@@ -17,12 +19,15 @@ class PublicPortalController extends Controller
return Inertia::render('PortalInvestidor', ['initialView' => 'opportunities', 'initialQuery' => $filters['q'] ?? '', 'initialSector' => $filters['sector'] ?? '']);
}
public function sector(string $slug): Response
public function sector(string $slug): Response|\Illuminate\Http\RedirectResponse
{
$sector = Sector::where('slug', $slug)->where('is_active', true)->firstOrFail();
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', 'sector' => $sector->only(['name', 'slug', 'description', 'short_description']),
'opportunities' => $sector->opportunities()->published()->latest('published_at')->limit(12)->get(['title', 'slug', 'province', 'summary'])]);
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
@@ -32,13 +37,13 @@ class PublicPortalController extends Controller
public function interest(?string $slug = null): Response
{
return $slug ? $this->detail($slug, 'interest') : Inertia::render('PortalInvestidor', ['initialView' => 'interest']);
return $slug ? $this->detail($slug, 'interest') : Inertia::render('PortalInvestidor', ['initialView' => 'interest', 'countries' => app(CountryCatalogue::class)->all()]);
}
private function detail(string $slug, string $view): Response
{
$opportunity = Opportunity::with('sector:id,name,slug')->published()->where('slug', $slug)->firstOrFail();
return Inertia::render('PortalInvestidor', ['initialView' => $view, 'initialOpportunity' => $opportunity]);
return Inertia::render('PortalInvestidor', ['initialView' => $view, 'initialOpportunity' => $opportunity, 'countries' => app(CountryCatalogue::class)->all()]);
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers;
use App\Services\SectorReconciliationExecution;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
final class SectorReconciliationController extends Controller
{
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');
}
public function apply(Request $request, SectorReconciliationExecution $execution)
{
return response()->json($execution->apply($request))->header('Cache-Control', 'private, no-store');
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentLegalSource;
use App\Services\SimulationTaxGovernanceAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** Explicit fiscal curation of an identified official source; not an independent legal opinion. */
final class SimulationFiscalSourceController extends Controller
{
public function store(Request $request): JsonResponse
{
SimulationTaxGovernanceAccess::requireRole($request->user(), 'editors');
$data = $request->validate([
'title' => ['required', 'string', 'max:255'], 'url' => ['required', 'url:https', 'max:2000'],
'diploma' => ['required', 'string', 'max:255'], 'article' => ['required', 'string', 'max:255'],
'competent_entity' => ['required', 'string', 'max:255'], 'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'sha256' => ['required', 'regex:/^[a-f0-9]{64}$/D'],
'citation' => ['required', 'string', 'min:10', 'max:2000'],
'official_origin' => ['required', 'string', 'min:10', 'max:2000'],
]);
return DB::transaction(function () use ($request, $data): JsonResponse {
$actor = $request->user();
$acceptance = ['scope' => 'fiscal_parameters', 'basis' => 'Tempo-official-sources-20261008',
'responsible_user_id' => $actor->id, 'sha256' => $data['sha256'], 'citation' => $data['citation'],
'official_origin' => $data['official_origin'], 'accepted_at' => now()->toISOString(),
'legal_opinion' => false, 'independent_review' => false];
unset($data['sha256'], $data['citation'], $data['official_origin']);
$source = InvestmentLegalSource::create([...$data, 'owner_id' => $actor->id, 'validator_id' => null,
'validation_status' => 'pending', 'validated_at' => null, 'version_no' => 1, 'fiscal_acceptance' => $acceptance]);
DB::table('audit_events')->insert(['source_system' => 'simulation-fiscal-maintenance',
'entity_type' => 'investment_legal_sources', 'entity_id' => $source->id, 'actor_user_id' => $actor->id,
'action' => 'official_source_acceptance', 'after_data' => json_encode([
'source' => $source->attributesToArray(), 'basis' => $acceptance,
'sha256' => hash('sha256', $source->toJson()),
], JSON_THROW_ON_ERROR)]);
return response()->json($source->fresh(), 201);
});
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers;
use App\Services\SimulationFiscalQaPreview;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleExpression;
use App\Services\SimulationTaxRuleGovernance;
use App\Support\IsolatedQaDatabase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use InvalidArgumentException;
class SimulationTaxRuleGovernanceController extends Controller
{
public function qaReview(Request $request, string $id, SimulationTaxRuleGovernance $governance): Response
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
$rule = $governance->show($request->user(), $id);
abort_unless($rule->status === 'draft' && $rule->rule_code === 'IVA-GERAL-BASE-LIQUIDA', 422,
'Seleccione o rascunho do primeiro lote fiscal.');
return Inertia::render('management/fiscal-qa-review', ['rule' => [
'id' => $rule->id, 'code' => $rule->rule_code, 'version' => $rule->rule_version,
]]);
}
public function qaPreview(Request $request, string $id, SimulationFiscalQaPreview $preview): JsonResponse
{
abort_unless(IsolatedQaDatabase::allowsSchemaChanges(), 404);
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
$input = $request->validate([
'base' => ['required', 'string', 'max:40', 'regex:/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/D'],
'reference_date' => ['required', 'date_format:Y-m-d'],
'currency' => ['required', 'in:MZN'],
'operation' => ['required', 'in:internal_general_net'],
]);
$result = $preview->create($request->user(), $id, $input, $key);
$simulation = $result['simulation'];
return response()->json(['data' => $simulation, 'replayed' => $result['replayed']], $result['replayed'] ? 200 : 202)
->header('Location', url('/api/v1/simulations/'.$simulation->id));
}
public function index(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
{
$filters = $request->validate([
'rule_code' => ['nullable', 'string', 'max:64'],
'component' => ['nullable', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
'scope_key' => ['nullable', 'string', 'max:160'],
'status' => ['nullable', 'in:draft,submitted,reviewed,rejected,approved,published,suspended,archived'],
'per_page' => ['nullable', 'integer', 'between:1,100'],
]);
return response()->json($governance->listing($request->user(), $filters));
}
public function store(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
{
$rule = $governance->create($request->user(), $this->validatedRule($request));
return response()->json($rule, 201)->header('Location', url('/api/v2/investment/admin/tax-rules/'.$rule->id));
}
public function show(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
{
return response()->json($governance->show($request->user(), $id));
}
public function update(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return response()->json($governance->update($request->user(), $id, (int) $data['version_no'], $this->validatedRule($request)));
}
public function transition(Request $request, SimulationTaxRuleGovernance $governance, string $id, string $action): JsonResponse
{
$data = $request->validate([
'version_no' => ['required', 'integer', 'min:1'],
'evidence' => ['required', 'string', 'min:20', 'max:2000'],
]);
return response()->json($governance->transition($request->user(), $id, (int) $data['version_no'], $action, $data['evidence']));
}
/** @return array<string,mixed> */
private function validatedRule(Request $request): array
{
$data = $request->validate([
'rule_code' => ['required', 'string', 'regex:/^[A-Z0-9][A-Z0-9._-]{2,63}$/'],
'rule_version' => ['required', 'integer', 'min:1'],
'component' => ['required', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
'scope_key' => ['required', 'string', 'min:2', 'max:160'],
'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'legal_source_id' => ['required', 'uuid', 'exists:investment_legal_sources,id'],
'source_sha256' => ['required', 'regex:/^[a-f0-9]{64}$/'],
'rule_definition' => ['required', 'array'],
'parameters' => ['required', 'array'],
'rounding_policy' => ['required', 'array'],
]);
foreach (['rule_definition', 'parameters', 'rounding_policy'] as $key) {
$json = json_encode($data[$key], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
Validator::make(['json' => $json], ['json' => ['max:50000']])->validate();
}
if (($data['rule_definition']['language'] ?? null) === 'investmoz.tax.v1') {
try {
app(SimulationTaxRuleExpression::class)->validate($data['rule_definition']);
} catch (InvalidArgumentException $exception) {
throw ValidationException::withMessages(['rule_definition' => $exception->getMessage()]);
}
try {
app(SimulationTaxParameterResolver::class)->validateParameters($data['parameters']);
} catch (InvalidArgumentException $exception) {
throw ValidationException::withMessages(['parameters' => $exception->getMessage()]);
}
}
return $data;
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Services\TerritoryGeometryImport;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class TerritoryGeometryImportController extends Controller
{
public function preview(Request $request, TerritoryGeometryImport $service): JsonResponse|Response
{
$data = $request->validate(['level' => 'nullable|integer|in:1,2,3']);
$plan = $service->preview($request->user(), (int) ($data['level'] ?? 1));
return $request->expectsJson() ? response()->json($plan) : Inertia::render('management/territory-geometry-import', ['plan' => $plan]);
}
public function apply(Request $request, TerritoryGeometryImport $service): JsonResponse
{
return response()->json($service->apply($request->user(), $request->all()));
}
public function reverse(Request $request, string $batch, TerritoryGeometryImport $service): JsonResponse
{
return response()->json($service->reverse($request->user(), $batch, $request->all()));
}
}
@@ -0,0 +1,350 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InvestmentSubmission;
use App\Models\User;
use App\Services\Ai\InvestorGuidance;
use App\Services\CountryCatalogue;
use App\Services\InstitutionalLinkMonitor;
use App\Services\InvestmentGeography;
use App\Services\InvestorContextConsumers;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class VirtualDeskController extends Controller
{
public function catalogue(Request $request, VirtualDesk $desk): JsonResponse|Response
{
$filters = $request->validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']);
$installed = $desk->installed();
$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));
$data = ['installed' => $installed, 'services' => $services->slice(($page - 1) * 12, 12)->values(), 'total' => $services->count(), 'page' => $page, 'filters' => $filters,
'prepared' => $installed ? [] : config('virtual-desk.services'), 'source' => config('virtual-desk.source'), 'authenticated' => (bool) $request->user(), 'support' => config('portal.support')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/catalogue', $data);
}
public function panel(Request $request, VirtualDesk $desk): JsonResponse|Response
{
$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')->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']),
'companies' => DB::table('investment_company_declarations')->where('user_id', $actor->id)->get(['id', 'payload'])->map(fn ($c) => ['id' => $c->id, 'name' => $desk->decode($c->payload)['name'] ?? 'Declaração empresarial']),
'submissions' => InvestmentSubmission::where('user_id', $actor->id)->get()->map(fn ($s) => ['id' => $s->id, 'status' => $s->status, 'name' => ($s->opportunity_id ? DB::table('opportunities')->where('id', $s->opportunity_id)->value('title') : null) ?? ($s->dossier_id ? DB::table('investment_dossiers')->where('id', $s->dossier_id)->value('title') : null) ?? ($s->payload['organisation'] ?? null)]),
'journeys' => DB::table('investor_journeys as j')->leftJoin('investment_dossiers as d', 'd.id', '=', 'j.dossier_id')->where('j.user_id', $actor->id)->whereNull('j.deleted_at')->get(['j.id', 'j.dossier_id', 'j.status', 'd.title as name']),
'simulationDrafts' => DB::table('simulation_form_drafts')->where('owner_user_id', $actor->id)->whereNull('deleted_at')->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->get(['id', 'version_no']),
'guidanceTasks' => app(InvestorGuidance::class)->installed() ? DB::table('tasks as t')->join('desk_cases as c', 'c.id', '=', 't.desk_case_id')
->whereNull('t.deleted_at')->when(! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->where('t.assignee_user_id', $actor->id)->orWhere(fn ($s) => $s->where('c.user_id', $actor->id)->whereNull('c.organization_id'))->orWhereIn('c.organization_id', $organizations)))
->orderByRaw('t.due_at ASC NULLS LAST')->get(['t.id', 't.title', 't.status', 't.due_at', 'c.id as case_id', 'c.title as case_title']) : [],
'organizations' => DB::table('organizations')->whereIn('id', $organizations)->get(['id', 'name']), 'manager' => $actor->can('manage-portal'), 'states' => config('virtual-desk.request_states')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/panel', $data);
}
public function createCase(Request $request, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$case = $desk->createCase($request->user(), $request->all());
return $request->expectsJson() ? response()->json(['id' => $case->id, 'version' => $case->version], 201) : redirect('/balcao-virtual/dossiers/'.$case->id)
->with('success', 'Dossier guardado e disponível para continuar. Próxima ação: escolha um serviço publicado e prepare o rascunho de apoio. Nenhum pedido foi submetido.');
}
public function showCase(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
{
$case = $desk->case($request->user(), $id);
$owner = $desk->owns($request->user(), $case, false);
$this->auditRead($request, 'desk_cases', $id);
$hidden = $owner ? ['_creation_hash'] : ['_creation_hash', 'profile', 'company', 'investment', 'name', 'email'];
if ((int) $case->user_id !== $request->user()->id) {
$hidden[] = 'investor_path';
}
$data = ['case' => [...(array) $case, 'payload' => Arr::except($desk->decode($case->payload), $hidden), 'workflow' => json_decode($case->workflow, true)],
'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' => $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' => 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 $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
}
/** @return Collection<int,\stdClass> */
private function documents(string $case, User $actor): Collection
{
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']);
}
/** @return Collection<int,array{id:mixed,request_id:mixed,action:mixed,version:mixed,created_at:mixed,name:mixed,payload:array<string,mixed>}> */
private function events(string $case, VirtualDesk $desk, User $actor): Collection
{
return DB::table('desk_events as e')->join('users as u', 'u.id', '=', 'e.actor_id')->where('e.case_id', $case)
->when(! $desk->owns($actor, $desk->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('e.request_id')->orWhereIn('e.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id'))))
->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 stage(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$case = $desk->stage($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['version' => $case->version]) : back()->with('success', 'Etapa e evidência registadas.');
}
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);
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201) : redirect('/balcao-virtual/pedidos/'.$row->id);
}
public function showRequest(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
{
$row = $desk->request($request->user(), $id);
$case = $desk->case($request->user(), $row->case_id);
$payload = $desk->decode($row->payload);
$definition = $desk->definition($row);
$territoryPaths = [];
foreach ($definition['fields'] as $key => $field) {
if ($field['type'] === 'territory') {
$territoryPaths[$key] = app(InvestmentGeography::class)->path($payload[$key] ?? null);
}
}
$messageKinds = [];
foreach (DB::table('desk_events')->where('request_id', $id)->where('action', 'message_added')->get() as $event) {
$original = $desk->decode($event->payload);
if (isset($original['message_id'], $original['kind'])) {
$messageKinds[$original['message_id']] = $original['kind'];
}
}
$this->auditRead($request, 'desk_requests', $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),
'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(),
'analysts' => $request->user()->can('manage-portal') ? User::orderBy('name')->get(['id', 'name']) : [],
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/request', $data);
}
public function save(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$row = $desk->save($request->user(), $id, $request->all(), $request->boolean('submit'));
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version, 'status' => $row->status, 'protocol' => $row->protocol]) : back()->with('success', $request->boolean('submit') ? 'Pedido submetido; protocolo disponível.' : 'Rascunho guardado.');
}
public function transition(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$row = $desk->transition($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['version' => $row->version, 'status' => $row->status]) : back()->with('success', 'Decisão interna registada.');
}
public function assignments(Request $request): JsonResponse|Response
{
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'])->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(\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,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.');
DB::table('desk_requests')->where('id', $id)->update(['analyst_id' => $analyst->id, 'due_at' => $data['due_at'] ?? null, 'version' => $row->version + 1, 'updated_at' => now()]);
$desk->event($case, $request->user(), 'analyst_assigned', [...$data, 'deadline_basis' => 'internal_target_not_legal_deadline'], DB::table('desk_requests')->where('id', $id)->first());
});
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Analista e meta interna atribuídos.');
}
public function message(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$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);
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();
if ($existing) {
abort_unless(Crypt::decryptString($existing->body) === $data['body'], 409);
return;
}
$messageId = (string) Str::uuid();
DB::table('desk_messages')->insert(['id' => $messageId, 'request_id' => $id, 'actor_id' => $request->user()->id, 'client_key' => $data['client_key'], 'body' => Crypt::encryptString($data['body'])]);
$desk->event($case, $request->user(), 'message_added', ['message_id' => $messageId,
'kind' => $desk->owns($request->user(), $case) ? 'investor_message' : 'responsible_response'], $row);
});
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Mensagem privada guardada.');
}
public function receipt(Request $request, string $id, VirtualDesk $desk): StreamedResponse|\Illuminate\Http\Response
{
$row = $desk->request($request->user(), $id);
abort_unless($row->protocol && $row->submitted_snapshot, 404);
$snapshot = $desk->decode($row->submitted_snapshot);
$format = $request->validate(['format' => 'nullable|in:html'])['format'] ?? null;
$this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded');
if ($format === 'html') {
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) {
echo 'Balcão Virtual — recibo de submissão interna'.PHP_EOL.$row->protocol.PHP_EOL.'Data: '.$row->submitted_at.PHP_EOL.'Versão: '.$row->version.PHP_EOL;
echo json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}, $row->protocol.'.txt', ['Content-Type' => 'text/plain; charset=utf-8', 'Cache-Control' => 'private, no-store']);
}
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|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;
try {
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 ($data['request_id'] ?? null) {
$row = $desk->request($request->user(), $data['request_id'], true, true);
abort_unless($row->case_id === $id && in_array($row->status, ['draft', 'returned'], true), 409, 'Anexos de pedidos submetidos são imutáveis; use a devolução para corrigir.');
}
abort_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');
$hash = hash_file('sha256', $file->getRealPath());
$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 {
$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 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;
}
}
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' => $documentId, 'source' => $data['source_reference'], 'consent' => true]);
});
} catch (\Throwable $e) {
if ($key) {
Storage::disk('local')->delete($key);
} throw $e;
}
if ($newId) {
ScanDocumentForMalware::dispatch($newId)->afterCommit();
}
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
{
$desk->case($request->user(), $id);
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('l.id', $document)->whereNull('d.deleted_at')->first(['d.*', 'l.request_id']);
if ($doc && $doc->request_id) {
$desk->request($request->user(), $doc->request_id);
}
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 404);
$response = app(VerifiedDocumentBinary::class)->respond($doc);
$this->auditRead($request, 'documents', $doc->id, 'document_downloaded');
return $response;
}
public function notification(Request $request, int $id): RedirectResponse
{
DB::table('desk_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]);
return back();
}
public function redirectLink(Request $request, string $id, VirtualDesk $desk): RedirectResponse
{
$case = $desk->case($request->user(), $request->validate(['case_id' => 'required|uuid'])['case_id']);
$link = DB::table('desk_institutional_links')->where('id', $id)->first();
if (! $link || ! app(InstitutionalLinkMonitor::class)->status($link)['available'] || $request->boolean('unavailable')) {
$desk->event($case, $request->user(), 'institutional_link_unavailable', ['link_id' => $id, 'contact' => $link?->contact, 'scope' => 'internal_services_remain_available']);
return redirect('/balcao-virtual/dossiers/'.$case->id)->with('warning', 'Ligação indisponível ou por verificar. Contacto alternativo: '.($link?->contact ?: (config('portal.support.email') ?: 'utilize o pedido de apoio neste dossier')).'. Pode continuar a preparação e os serviços internos.');
}
abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422);
$desk->event($case, $request->user(), 'institutional_referral', ['link_id' => $id, 'version' => $link->version, 'purpose' => $link->purpose]);
// No personal data, case ID or protocol appended to the external URL.
return redirect()->away($link->url)->withHeaders(['Referrer-Policy' => 'no-referrer']);
}
private function auditRead(Request $request, string $type, string $id, string $action = 'consulted'): void
{
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => $type, 'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action]);
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers;
use App\Services\VirtualDeskInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class VirtualDeskInstallationController extends Controller
{
public function reviewLink(Request $request, string $id): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'available' => 'required|boolean',
'evidence' => 'required|string|min:10|max:3000', 'contact' => 'nullable|string|max:255']);
DB::transaction(function () use ($request, $id, $data) {
$link = DB::table('desk_institutional_links')->where('id', $id)->lockForUpdate()->first();
abort_unless($link !== null, 404);
abort_unless((int) $link->version === (int) $data['version'], 409);
abort_unless(filter_var($link->url, FILTER_VALIDATE_URL) && parse_url($link->url, PHP_URL_SCHEME) === 'https', 422);
DB::table('desk_institutional_links')->where('id', $id)->update(['status' => $data['available'] ? 'active' : 'unavailable',
'contact' => $data['contact'] ?? $link->contact, 'verified_at' => now(), 'verified_by' => $request->user()->id,
'version' => $link->version + 1, 'updated_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_institutional_links', 'entity_id' => $id,
'actor_user_id' => $request->user()->id, 'action' => 'institutional_link_checked',
'after_data' => json_encode(['url' => $link->url, 'prior_version' => $link->version, ...$data, 'scope' => 'availability_only_not_legal_approval'])]);
});
return $request->expectsJson() ? response()->json(['checked' => true]) : back()->with('success', 'Conferência de disponibilidade registada por sete dias.');
}
public function sources(Request $request): JsonResponse|Response
{
$read = function (string $name): array {
$file = storage_path('app/private/virtual-desk/'.$name.'.json');
if (! is_file($file)) {
return [];
}
$bytes = file_get_contents($file);
abort_unless(is_string($bytes), 503);
return json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
};
$inventory = $read('source-inventory');
$forms = $read('form-register');
$data = ['total' => count($inventory), 'attachmentCount' => array_sum(array_map(fn ($item) => count($item['attachments'] ?? []), $inventory)),
'forms' => array_map(fn ($item) => ['identifier' => $item['identifier'], 'sha256' => $item['sha256'], 'size' => $item['size'], 'edition' => $item['edition'],
'provenance' => str_contains($item['path'], DIRECTORY_SEPARATOR.'Scan'.DIRECTORY_SEPARATOR) ? 'Acervo disponibilizado — digitalização' : 'Acervo disponibilizado — fonte de desenho ou template',
'consultation' => $item['consultation'], 'legal_documentary_status' => $item['legal_documentary_status'], 'rule_use' => $item['rule_use'], 'official_confirmation' => $item['official_confirmation'], 'fields_status' => $item['fields_status']], $forms),
'source' => config('virtual-desk.source')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/sources', $data);
}
public function preview(Request $request, VirtualDeskInstallation $installation): JsonResponse|Response
{
$data = $installation->preview();
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/install', $data);
}
public function apply(Request $request, VirtualDeskInstallation $installation): JsonResponse|RedirectResponse
{
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'accepted']);
$installation->apply($data['hash'], $request->user());
return $request->expectsJson() ? response()->json(['installed' => true]) : back()->with('success', 'Instalação concluída; serviços ainda exigem publicação explícita.');
}
public function publishService(Request $request): JsonResponse|RedirectResponse
{
$data = $request->validate(['code' => ['required', Rule::in(array_keys(config('virtual-desk.services')))], 'confirm' => 'accepted']);
$row = DB::transaction(function () use ($request, $data) {
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' && json_decode($existing->definition, true) === $definition) {
return $existing;
}
$id = (string) Str::uuid();
DB::table('desk_service_versions')->insert(['id' => $id, 'code' => $data['code'], 'version' => ($existing->version ?? 0) + 1, 'status' => 'published',
'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'])]);
return DB::table('desk_service_versions')->where('id', $id)->first();
});
return $request->expectsJson() ? response()->json(['id' => $row->id], 201) : back()->with('success', 'Serviço de preparação interna publicado.');
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers;
use App\Services\Ai\InvestorGuidanceEvidence;
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;
/** Manual operations use the same requests, documents and encrypted audit history. */
class VirtualDeskOperationsController extends Controller
{
private function result(Request $request): JsonResponse|RedirectResponse
{
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Registo interno guardado; não constitui confirmação de uma instituição externa.');
}
private function reviewer(Request $request, VirtualDesk $desk, \stdClass $case, ?\stdClass $row): void
{
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): \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')
->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.');
$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;
}
public function documentReview(Request $request, string $id, string $document, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$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) {
$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;
abort_if($row && $row->case_id !== $id, 422);
$this->reviewer($request, $desk, $case, $row);
$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,
'scope' => 'internal_documentary_check_not_official_approval_registration_or_license',
], $row);
});
return $this->result($request);
}
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', '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);
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.
$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');
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' => $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);
}
public function paymentReview(Request $request, string $id, string $payment, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$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);
$this->reviewer($request, $desk, $case, $row);
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);
$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);
}
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, '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);
}
public function communication(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$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);
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
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']) {
abort_unless(array_intersect_key($saved, $data) === $data, 409);
return;
}
}
$desk->event($case, $request->user(), 'manual_communication_recorded', [...$data, 'scope' => 'manual_record_not_portal_delivery_confirmation'], $row);
});
return $this->result($request);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class AuditProfileDecision
{
public function handle(Request $request, Closure $next): Response
{
$access = app(PortalProfileAccess::class);
if (! $request->user() || ! $access->governanceAvailable() || ! preg_match('#^(perfil/|perfis/|minhas-oportunidades)#', $request->path())) {
return $next($request);
}
try {
$response = $next($request);
} catch (\Throwable $error) {
$status = $error instanceof HttpExceptionInterface ? $error->getStatusCode() : ($error instanceof ValidationException ? 422 : 500);
$this->record($request, $access, $status);
throw $error;
}
if ($response->getStatusCode() >= 400) {
$this->record($request, $access, $response->getStatusCode());
}
return $response;
}
private function record(Request $request, PortalProfileAccess $access, int $status): void
{
$access->audit($request->user(), 'request_denied', ['method' => $request->method(), 'path' => $request->path(), 'status' => $status]);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class CorrelationId
{
/** @param Closure(Request): Response $next */
public function handle(Request $request, Closure $next): Response
{
$provided = $request->header('X-Correlation-ID');
$correlationId = is_string($provided) && Str::isUuid($provided)
? $provided
: (string) Str::uuid();
if ($provided !== null && ! Str::isUuid($provided)) {
return response()->json([
'message' => 'O identificador de correlação deve ser um UUID válido.',
'code' => 'invalid_correlation_id',
'correlation_id' => $correlationId,
], 422)->header('X-Correlation-ID', $correlationId);
}
$request->attributes->set('correlation_id', $correlationId);
$response = $next($request);
$response->headers->set('X-Correlation-ID', $correlationId);
return $response;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Services\DocumentManagementAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureDocumentManagement
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor, 403);
$access = app(DocumentManagementAccess::class);
if ($access->full($actor)) {
return $next($request); // Preserve existing management controls.
}
$allowed = $access->limited($actor) || $access->editorial($actor);
$access->audit($actor, $allowed ? 'document_access_allowed' : 'document_access_denied', [
'scope' => $access->editorial($actor) ? 'approved-editorial-catalogue-and-own-files' : 'own-portal-services-files', 'method' => $request->method(), 'path' => $request->path(),
]);
abort_unless($allowed, 403, 'O acesso documental exige autorização, email confirmado e autenticação MFA nesta sessão.');
return $next($request);
}
}
@@ -0,0 +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;
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePortalProfileAdministration
{
public function handle(Request $request, Closure $next): Response
{
abort_unless(config('portal.profile_administration_enabled') || config('portal.profile_access_enabled'), 404);
abort_unless(app(PortalProfileAccess::class)->governanceAvailable(), 503, 'Autorização por perfis ainda não instalada.');
return $next($request);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePortalProfiles
{
public function handle(Request $request, Closure $next): Response
{
abort_unless(config('portal.profile_access_enabled'), 404);
abort_unless(app(PortalProfileAccess::class)->available(), 503, 'Autorização por perfis ainda não instalada.');
return $next($request);
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
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, 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)
|| ($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);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use App\Services\ScopedPortalAdministration;
use App\Services\SimulationTaxGovernanceAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureScopedPortalAdministration
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor !== null, 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/*',
'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/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/*');
$allowed = ($fiscal && SimulationTaxGovernanceAccess::maintainer($actor)) || ($interests ? $scope->interestOperator($actor) : ($catalogue && $scope->administrative($actor)));
abort_unless($allowed || ($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor')), 403, 'Acesso reservado ao âmbito já designado.');
$response = $next($request);
if (! $request->isMethod('GET') && $response->getStatusCode() < 400) {
app(PortalProfileAccess::class)->audit($actor, 'scoped_administration', [
'method' => $request->method(), 'route' => $request->route()?->uri(), 'scope' => $fiscal ? 'fiscal-maintenance' : ($interests ? 'assigned-interest' : 'catalogue-administration')]);
}
return $response;
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class EnsureSimulationRecoveryCookie
{
public const NAME = 'investmoz_simulation_recovery';
public const DAYS = 30;
public function handle(Request $request, Closure $next): Response
{
$token = $request->cookie(self::NAME);
$issued = ! is_string($token) || strlen($token) < 32 || strlen($token) > 256;
if (! is_string($token) || strlen($token) < 32 || strlen($token) > 256) {
$provided = $request->header('X-Simulation-Token');
$token = is_string($provided) && strlen($provided) >= 32 && strlen($provided) <= 256
? $provided
: Str::random(96);
}
$request->headers->set('X-Simulation-Token', $token);
$response = $next($request);
$forget = $request->isMethod('DELETE')
&& $request->path() === 'api/v1/simulation-drafts/current'
&& $response->getStatusCode() === 204;
$activity = $response->isSuccessful() && $this->isExplicitDraftActivity($request, $response);
$replay = $response->isSuccessful() && $this->isDraftActivityReplay($request, $response);
if (! $issued && ! $activity && ! $replay && ! $forget) {
return $response;
}
// Successful draft writes reset the server deadline to now + DAYS, so
// refresh the cookie by the same duration. Routine reads never refresh it.
$minutes = $forget ? 0 : self::DAYS * 24 * 60;
if ($replay) {
$payload = json_decode((string) $response->getContent(), true);
$expiry = is_array($payload) ? ($payload['expires_at'] ?? null) : null;
if (is_string($expiry)) {
try {
$secondsLeft = Carbon::parse($expiry)->getTimestamp() - now()->getTimestamp();
$minutes = max(1, (int) ceil($secondsLeft / 60));
} catch (\Throwable) {
// Preserve the standard cookie lifetime if the response has no valid deadline.
}
}
}
return $response->withCookie(cookie(
self::NAME,
$forget ? '' : $token,
$minutes,
'/api/v1',
null,
$request->isSecure(),
true,
false,
'lax',
));
}
private function isExplicitDraftActivity(Request $request, Response $response): bool
{
if ($request->isMethod('POST') && $request->path() === 'api/v1/simulation-drafts') {
return $response->getStatusCode() === 201;
}
return ($request->isMethod('PUT') && preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}$#i', $request->path()) === 1)
|| ($request->isMethod('POST') && preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}/(?:conversions|touch)$#i', $request->path()) === 1);
}
private function isDraftActivityReplay(Request $request, Response $response): bool
{
if (! $request->isMethod('POST') || $response->getStatusCode() !== 200) {
return false;
}
if ($request->path() !== 'api/v1/simulation-drafts'
&& preg_match('#^api/v1/simulation-drafts/[0-9a-f-]{36}/conversions$#i', $request->path()) !== 1) {
return false;
}
$payload = json_decode((string) $response->getContent(), true);
return is_array($payload) && ($payload['data']['replayed'] ?? $payload['replayed'] ?? false) === true;
}
}

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