first commit
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-09-28 10:01:09 +02:00
commit 250ac35fd1
481 changed files with 92144 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+87
View File
@@ -0,0 +1,87 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
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=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Comma-separated emails of verified accounts authorised to manage the portal.
PORTAL_MANAGER_EMAILS=
PORTAL_NOTIFICATIONS_ENABLED=false
PORTAL_NOTIFICATION_RECIPIENTS=
PORTAL_SUPPORT_EMAIL=
PORTAL_SUPPORT_PHONE=
PORTAL_SUPPORT_ADDRESS=
PORTAL_SUPPORT_HOURS=
# Internal education intake; activate only after schema and access validation.
EDUCATION_ENABLED=false
# Internal evidence/rules baseline; no generative provider or training enabled.
INVESTMOZ_AI_ENABLED=false
# Backend-only; enable only after SuiteCRM homologation and OAuth provisioning.
SUITECRM_ENABLED=false
SUITECRM_BASE_URL=
SUITECRM_CLIENT_ID=
SUITECRM_CLIENT_SECRET=
SUITECRM_CACHE_STORE=database
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
CHANGELOG.md export-ignore
README.md export-ignore
.github/workflows/browser-tests.yml export-ignore
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
groups:
github-actions:
patterns:
- "*"
+70
View File
@@ -0,0 +1,70 @@
name: tests
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:17-3.5
env:
POSTGRES_DB: investmoz_test
POSTGRES_USER: investmoz_test
POSTGRES_PASSWORD: test-only-password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U investmoz_test -d investmoz_test"
--health-interval 10s --health-timeout 5s --health-retries 5
env:
APP_ENV: testing
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_DATABASE: investmoz_test
DB_USERNAME: investmoz_test
DB_PASSWORD: test-only-password
MAIL_MAILER: array
CACHE_STORE: array
SESSION_DRIVER: array
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.5'
extensions: pdo_pgsql, intl, mbstring
tools: composer:v2
coverage: none
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: Setup Application
run: |
composer install --no-interaction --prefer-dist
cp .env.example .env
php artisan key:generate
npm ci
npm run build
- name: Run CI Checks
run: composer ci:check
- name: Test frontend API contracts
run: node --test tests/frontend/portal-api.test.mjs
+32
View File
@@ -0,0 +1,32 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/storage/app/private
/storage/logs
/storage/framework
/resources/js/actions
/resources/js/routes
/resources/js/wayfinder
/vendor
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed
+1
View File
@@ -0,0 +1 @@
ignore-scripts=true
+69
View File
@@ -0,0 +1,69 @@
# Portal do Investidor — esqueleto Laravel
Estes ficheiros correspondem ao domínio de **sectores**, **oportunidades** e
**manifestações de interesse** do Portal do Investidor. Foram desenhados
para casar exactamente com os dados de exemplo usados no protótipo React
(`PortalInvestidor.jsx`) — mesmos slugs de sector, mesmas referências de
oportunidade.
## Como instalar num projecto Laravel novo
```bash
composer create-project laravel/laravel portal-investidor
cd portal-investidor
```
Copie as pastas deste pacote para dentro do projecto (substituindo os
ficheiros correspondentes):
```
app/Models/*.php -> app/Models/
app/Http/Controllers/Api/V1/*.php -> app/Http/Controllers/Api/V1/
app/Http/Requests/*.php -> app/Http/Requests/
database/migrations/*.php -> database/migrations/
database/seeders/*.php -> database/seeders/
routes/api.php -> routes/api.php (substitui o existente)
```
Registe os seeders em `database/seeders/DatabaseSeeder.php`:
```php
public function run(): void
{
$this->call([
SectorSeeder::class,
OpportunitySeeder::class,
]);
}
```
Configure a base de dados no `.env` e depois:
```bash
php artisan migrate:fresh --seed
php artisan serve
```
A API fica disponível em `http://localhost:8000/api/v1/...`:
- `GET /api/v1/home`
- `GET /api/v1/sectors`
- `GET /api/v1/sectors/{slug}`
- `GET /api/v1/opportunities/search?q=&sector=&province=&maturity_level=`
- `GET /api/v1/opportunities/{slug}`
- `POST /api/v1/interest-requests`
## Ligar ao frontend React
A homepage mantida é `resources/js/pages/PortalInvestidor.tsx`, já ligada às
APIs do portal. O catálogo e os novos percursos estão em
`resources/js/pages/investment/`; a extensão usa `/api/v1/investment` e mantém
os endpoints anteriores. O ficheiro JSX antigo é um protótipo não montado.
## Nota
A aplicação Laravel/React está executável localmente. A extensão do catálogo,
manifestações, perfil, empresas declaradas, conversa e notificações está documentada
em [Unificação do portal](docs/investmoz/unification/README.md), com contratos de API,
migrações, backups verificados, testes e limites da entrega. A consolidação da outra
máquina foi adiada pelo proprietário para uma fase futura.
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Concerns;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands;
use App\Services\Ai\EvidenceRepository;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
final class AiBenchmark extends Command
{
protected $signature = 'ai:benchmark';
protected $description = 'Mede recuperação lexical PT/EN com dados sintéticos e rollback numa base *_test';
public function handle(EvidenceRepository $evidence): int
{
if (! app()->environment('testing') || DB::connection()->getDriverName() !== 'pgsql'
|| ! str_ends_with(DB::connection()->getDatabaseName(), '_test')
|| DB::connection()->getDatabaseName() !== DB::selectOne('select current_database() as name')->name) {
$this->error('Apenas numa base PostgreSQL isolada *_test, com APP_ENV=testing.');
return self::FAILURE;
}
$cases = [
['pt', 'formação técnica', 'Exemplo sintético: formação técnica de electricistas em oficinas.', true],
['pt', 'cadeia logística', 'Exemplo sintético: a cadeia logística inclui armazenamento e transporte.', true],
['pt', 'energia solar', 'Exemplo sintético: manutenção de equipamentos de energia solar.', true],
['pt', 'taxa oficial', 'Exemplo sintético sem informação legal.', false],
['en', 'vocational training', 'Synthetic example: vocational training for equipment maintenance.', true],
['en', 'logistics corridor', 'Synthetic example: a logistics corridor connects warehouses and transport hubs.', true],
['en', 'solar energy', 'Synthetic example: solar energy equipment requires maintenance.', true],
['en', 'official tax', 'Synthetic example with no legal information.', false],
];
DB::beginTransaction();
try {
// Unique synthetic fixtures are visible only within this transaction.
$author = DB::table('users')->insertGetId(['name' => 'Synthetic benchmark author', 'email' => Str::uuid().'@example.invalid', 'password' => 'unused']);
$reviewer = DB::table('users')->insertGetId(['name' => 'Synthetic benchmark reviewer', 'email' => Str::uuid().'@example.invalid', 'password' => 'unused']);
$measurements = [];
$passed = 0;
foreach ($cases as [$language, $query, $text, $expected]) {
$document = (string) Str::uuid();
$source = (string) Str::uuid();
DB::table('documents')->insert(['id' => $document, 'file_name' => 'synthetic.txt', 'media_type' => 'text/plain',
'storage_key' => 'synthetic-'.$document, 'sha256' => hash('sha256', $text), 'status' => 'active', 'data_classification' => 'internal']);
DB::table('ai_sources')->insert(['id' => $source, 'document_id' => $document, 'document_version' => 1,
'document_checksum' => hash('sha256', $text), 'title' => 'Synthetic benchmark', 'language' => $language,
'nature' => 'illustrative', 'source_url' => 'https://example.org/synthetic', 'source_owner' => 'Test fixture',
'evidence_reference' => 'Synthetic benchmark only', 'source_date' => now()->toDateString(),
'valid_until' => now()->addDay()->toDateString(), 'status' => 'approved', 'submitted_by' => $author,
'reviewed_by' => $reviewer, 'reviewed_at' => now()]);
DB::table('ai_chunks')->insert(['id' => (string) Str::uuid(), 'source_id' => $source, 'position' => 0,
'language' => $language, 'content' => $text, 'checksum' => hash('sha256', $text)]);
$latencies = [];
for ($i = 0; $i < 5; $i++) {
$start = hrtime(true);
$results = $evidence->search($query, $language, ['source_id' => $source]);
$latencies[] = round((hrtime(true) - $start) / 1000000, 3);
}
$ok = ($results !== []) === $expected;
$passed += $ok ? 1 : 0;
sort($latencies);
$measurements[] = ['language' => $language, 'query' => $query, 'expected_match' => $expected,
'passed' => $ok, 'p50_ms' => $latencies[2], 'max_ms' => $latencies[4]];
}
$this->line(json_encode(['executed_at' => now()->toIso8601String(), 'backend' => 'PostgreSQL FTS', 'model' => null,
'synthetic' => true, 'passed' => $passed, 'cases' => count($cases), 'repetitions_per_case' => 5,
'measurements' => $measurements, 'generative_benchmark' => 'not_executed_no_runtime_or_model',
'limitations' => ['small_single_source_cases', 'not_a_representative_benchmark', 'not_human_validated_PT_MZ', 'no_concurrency_or_load_test']], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
return $passed === count($cases) ? self::SUCCESS : self::FAILURE;
} finally {
DB::rollBack();
}
}
}
@@ -0,0 +1,59 @@
<?php
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 $description = 'Import the v4 portfolio and optionally publish unchanged documentary dossiers with owner approval';
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}.");
return self::SUCCESS;
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Laravel\Chisel\Chisel;
use Laravel\Chisel\Question;
use Laravel\Chisel\Script;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\spin;
class InstallFeaturesCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'install:features
{--answers= : JSON string of answers to skip interactive prompts}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Choose which starter kit features to keep';
public function handle(): int
{
if ($this->shouldDeferInstallerHooks()) {
return self::SUCCESS;
}
if (! file_exists(base_path('chisel.php'))) {
return self::SUCCESS;
}
/** @var Script $script */
$script = require base_path('chisel.php');
$providedAnswers = $this->option('answers') === null
? []
: json_decode((string) $this->option('answers'), true, 512, JSON_THROW_ON_ERROR);
$answers = $script
->collectAnswers()
->onQuestion(fn (Question $question) => multiselect(
label: $question->label,
options: $question->options,
default: $question->default ?? [],
required: $question->required,
hint: $question->hint,
))
->interactive($this->input->isInteractive())
->withAnswers($providedAnswers);
$skipNode = $this->shouldSkipNode();
if (! $skipNode) {
$this->installNodeDependencies();
}
$script->chisel($answers);
if (! $skipNode) {
$this->buildAssets();
}
return self::SUCCESS;
}
protected function shouldDeferInstallerHooks(): bool
{
if ($this->option('answers') !== null) {
return false;
}
return $this->installerFlag('LARAVEL_INSTALLER_DEFER_HOOKS');
}
protected function shouldSkipNode(): bool
{
return $this->installerFlag('LARAVEL_INSTALLER_NO_NODE');
}
protected function installerFlag(string $name): bool
{
return filter_var(
$_ENV[$name] ?? $_SERVER[$name] ?? getenv($name),
FILTER_VALIDATE_BOOL,
);
}
protected function installNodeDependencies(): void
{
$npm = Chisel::in(base_path())->npm();
$packageManager = $npm->packageManager();
spin(
fn () => $npm->install(),
"Installing dependencies with {$packageManager->value}...",
);
}
protected function buildAssets(): void
{
$npm = Chisel::in(base_path())->npm();
spin(
fn () => $npm->run('build'),
'Building assets...',
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PortalReadiness extends Command
{
protected $signature = 'portal:readiness';
protected $description = 'Verifica requisitos de publicação sem alterar dados nem revelar segredos';
public function handle(): int
{
$checks = [
'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='),
'Driver PostgreSQL' => extension_loaded('pdo_pgsql'),
'Ambiente production' => app()->isProduction(),
'Debug desactivado' => ! config('app.debug'),
'APP_KEY definida' => filled(config('app.key')),
'APP_URL HTTPS' => str_starts_with(config('app.url'), 'https://'),
'Cookies seguros' => (bool) config('session.secure'),
'Assets compilados' => is_file(public_path('build/manifest.json')),
'Sem ficheiro Vite hot' => ! is_file(public_path('hot')),
'Storage gravável' => is_writable(storage_path()) && is_writable(base_path('bootstrap/cache')),
'Gestores configurados' => count(config('portal.managers')) > 0,
'Correio real configurado' => ! in_array(config('mail.default'), ['log', 'array'], true),
];
try {
DB::select('select 1');
$checks['Ligação à base de dados'] = true;
$checks['PostGIS instalado'] = (bool) DB::selectOne("select exists(select 1 from pg_extension where extname = 'postgis') as installed")->installed;
$checks['Migrations aplicadas'] = count(array_diff(
array_keys(app('migrator')->getMigrationFiles(database_path('migrations'))),
app('migration.repository')->getRan(),
)) === 0;
} catch (\Throwable) {
$checks['Base de dados e migrations verificáveis'] = false;
}
if (config('portal.notifications_enabled')) {
$checks['Destinatários válidos'] = count(config('portal.notification_recipients')) > 0
&& collect(config()->array('portal.notification_recipients'))->every(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL) !== false);
$checks['Fila database na mesma ligação'] = ! config('queue.connections.database.after_commit')
&& (! config('queue.connections.database.connection') || config('queue.connections.database.connection') === config('database.default'));
}
foreach ($checks as $label => $passed) {
$this->line(($passed ? '[OK] ' : '[PENDENTE] ').$label);
}
$this->line('Este diagnóstico não testa entrega de email, TLS externo, backups ou execução do worker.');
return in_array(false, $checks, true) ? self::FAILURE : self::SUCCESS;
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use App\Models\Opportunity;
use App\Services\InvestmentIdentity;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ReconcileInvestmentIdentity extends Command
{
protected $signature = 'investment:reconcile-identities {--apply : Apply direct opportunity-ID mappings after backup}';
protected $description = 'Dry-run or apply idempotent canonical mappings without changing legacy records or merging projects';
public function handle(InvestmentIdentity $identity): int
{
if (! app()->environment('local', 'testing')) {
$this->error('This migration command is restricted to local/testing environments.');
return self::FAILURE;
}
$report = DB::transaction(function () use ($identity) {
$ids = Opportunity::orderBy('id')->pluck('id');
$missing = $ids->diff(DB::table('investment_item_opportunities')->pluck('opportunity_id'))->values();
if ($this->option('apply')) {
foreach ($missing as $id) {
$identity->forOpportunity($id);
}
}
return ['generated_at' => now()->toIso8601String(), 'mode' => $this->option('apply') ? 'apply' : 'dry-run',
'opportunity_count' => $ids->count(), 'missing_before' => $missing->count(),
'missing_after' => $ids->diff(DB::table('investment_item_opportunities')->pluck('opportunity_id'))->count(),
'unmapped_project_count' => DB::table('projects')->whereNull('deleted_at')->whereNotIn('id', DB::table('investment_item_projects')->select('project_id'))->count(),
'changes_to_legacy_records' => 0, 'automatic_project_merges' => 0,
'rule' => 'Map stable opportunity primary keys only. Project correspondence requires documentary reconciliation.'];
});
$path = 'zone-reconciliation/'.now()->format('Ymd-His').'-'.Str::uuid().'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
$this->line(json_encode([...$report, 'private_report' => $path], JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Integrations\SuiteCrm\SuiteCrmClient;
use App\Integrations\SuiteCrm\SuiteCrmException;
use Illuminate\Console\Command;
use Throwable;
class SuiteCrmProbe extends Command
{
protected $signature = 'suitecrm:probe {--module=Contacts : Modulo cujos metadados serao consultados}';
protected $description = 'Verifica OAuth e leitura de metadados sem escrever registos comerciais';
public function handle(SuiteCrmClient $client): int
{
try {
$response = $client->fields((string) $this->option('module'));
if (! isset($response['data']) || ! is_array($response['data']) || $response['data'] === []) {
$this->error('Resposta sem metadados utilizaveis. Integracao nao validada.');
return self::FAILURE;
}
} catch (SuiteCrmException $exception) {
$this->error('SuiteCRM indisponivel: '.$exception->reason);
return self::FAILURE;
} catch (Throwable) {
// Cache/database exceptions may contain secrets: never render their messages.
$this->error('Falha interna no diagnostico. Verifique cache, chave da aplicacao e configuracao.');
return self::FAILURE;
}
$this->info('OAuth e leitura de metadados confirmados. Nenhum registo comercial alterado.');
$this->warn('Este teste nao valida permissoes de escrita, sincronizacao, reconciliacao ou prontidao para producao.');
return self::SUCCESS;
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
namespace App\Http\Controllers;
use App\Models\AiRun;
use App\Models\Opportunity;
use App\Services\Ai\AiGateway;
use App\Services\Ai\EvidenceRepository;
use App\Services\Ai\SourceIngestion;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
final class AiController extends Controller
{
public function index(): Response
{
return Inertia::render('ai/index', [
'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')
->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(),
]);
}
public function run(Request $request, AiGateway $gateway): JsonResponse|RedirectResponse
{
$data = $request->validate([
'capability' => ['required', Rule::in(config('investmoz_ai.capabilities'))],
'purpose' => ['required', Rule::in(['investment_information', 'interest_triage', 'data_quality', 'execution_monitoring'])],
'language' => ['required', Rule::in(['pt', 'en'])],
'query' => ['required_if:capability,assistant,search', 'nullable', 'string', 'min:3', 'max:1000'],
'source_id' => ['required_if:capability,summary', 'nullable', 'uuid', 'exists:ai_sources,id'],
'interest_id' => ['required_if:capability,classify', 'nullable', 'integer', 'min:1'],
'sector_id' => ['nullable', 'integer', 'exists:sectors,id'],
'province' => ['nullable', 'string', 'max:100'],
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
'nature' => ['nullable', Rule::in(['official', 'historical', 'illustrative', 'pending'])],
]);
$expected = match ($data['capability']) {
'classify' => 'interest_triage', 'quality' => 'data_quality', 'sla' => 'execution_monitoring', default => 'investment_information',
};
abort_unless($data['purpose'] === $expected, 422, 'Finalidade incompatível com a capacidade.');
$run = $gateway->execute($request->user(), $data);
return $request->expectsJson() ? response()->json(['id' => $run->id, 'review_status' => 'pending'], 201)
: redirect('/management/ai/runs/'.$run->id);
}
public function show(Request $request, string $id, EvidenceRepository $evidence): JsonResponse|Response
{
$run = AiRun::findOrFail($id);
$available = $this->resultAvailable($run, $evidence);
$data = ['run' => $run->only(['id', 'capability', 'mode', 'rules_version', 'prompt_version', 'latency_ms', 'version_no',
'review_status', 'review_requested', 'created_at']), 'result' => $available ? $run->result : null,
'unavailable' => ! $available];
return $request->expectsJson() ? response()->json($data) : Inertia::render('ai/result', $data);
}
private function resultAvailable(AiRun $run, EvidenceRepository $evidence): bool
{
$available = $evidence->allStillEligible($run->source_ids)
&& DB::table('ai_controls')->where('capability', $run->capability)->where('suspended', false)->exists();
if ($run->capability === 'classify') {
$interestId = $run->result['items'][0]['record_id'] ?? null;
$reference = DB::table('interest_requests')->where('id', $interestId)->whereNull('deleted_at')->value('reference');
$available = $available && $reference && DB::table('consents')->where('subject_ref', $reference)
->where('purpose', 'ai_assistance')->where('status', 'granted')->whereNotNull('granted_at')->where('granted_at', '<=', now())
->whereNull('withdrawn_at')->whereNull('deleted_at')->exists();
}
if ($run->capability === 'recommend') {
$ids = array_column($run->result['items'], 'record_id');
$available = $available && Opportunity::published()->whereIn('id', $ids)->count() === count($ids);
}
return $available;
}
public function source(Request $request, SourceIngestion $ingestion): JsonResponse|RedirectResponse
{
$data = $request->validate([
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'language' => ['required', 'in:pt,en'], 'nature' => ['required', 'in:official,historical,illustrative,pending'],
'source_url' => ['required', 'url:https', 'max:2000'], 'source_owner' => ['required', 'string', 'max:200'],
'evidence_reference' => ['required', 'string', 'min:10', 'max:2000'],
'source_date' => ['required', 'date_format:Y-m-d', 'before_or_equal:today'],
'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:today', 'after_or_equal:source_date'],
'authorised' => ['accepted'],
]);
unset($data['authorised']);
$ingestion->ingest($data, $request->user()->id);
return $this->completed($request, 'Fonte indexada em quarentena lógica; requer revisão por outra pessoa antes de pesquisa.');
}
public function document(Request $request): JsonResponse|RedirectResponse
{
$request->validate(['file' => ['required', 'file', 'mimetypes:text/plain', 'max:195'], 'authorised' => ['accepted']]);
$file = $request->file('file');
$text = $file->get();
if ($text === false) {
abort(422, 'Não foi possível ler o ficheiro.');
}
abort_unless(mb_check_encoding($text, 'UTF-8') && ! str_contains($text, "\0") && trim($text) !== '', 422, 'Apenas texto UTF-8 sem conteúdo binário.');
$id = (string) Str::uuid();
$key = 'ai-documents/'.$id.'.txt';
abort_unless(Storage::disk('local')->put($key, $text) !== false, 500, 'Não foi possível guardar o documento.');
try {
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']);
$this->audit('documents', $id, 'plain_text_registered', $request->user()->id);
});
} catch (\Throwable $exception) {
Storage::disk('local')->delete($key);
throw $exception;
}
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.');
}
public function reviewSource(Request $request, string $id): JsonResponse|RedirectResponse
{
$data = $request->validate(['status' => ['required', 'in:approved,revoked'], 'expected_status' => ['required', 'in:pending,approved,revoked']]);
DB::transaction(function () use ($request, $id, $data): void {
$source = DB::table('ai_sources')->where('id', $id)->lockForUpdate()->first();
abort_unless($source !== null, 404);
abort_unless($source->status === $data['expected_status'], 409, 'Estado alterado. Recarregue a fonte.');
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')
->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.');
}
DB::table('ai_sources')->where('id', $id)->update(['status' => $data['status'], 'reviewed_by' => $request->user()->id,
'reviewed_at' => now(), 'updated_at' => now()]);
$this->audit('ai_sources', $id, 'source_'.$data['status'], $request->user()->id);
});
return $this->completed($request, 'Estado de autorização actualizado; não constitui validação jurídica da fonte.');
}
public function feedback(Request $request, string $id): JsonResponse|RedirectResponse
{
$data = $request->validate(['rating' => ['required', 'in:helpful,unhelpful,unsafe'], 'review_requested' => ['required', 'boolean']]);
DB::transaction(function () use ($request, $id, $data): void {
$run = AiRun::whereKey($id)->lockForUpdate()->firstOrFail();
DB::table('ai_feedback')->updateOrInsert(['run_id' => $id, 'user_id' => $request->user()->id],
['rating' => $data['rating'], 'created_at' => now(), 'updated_at' => now()]);
if ($data['review_requested'] || $data['rating'] === 'unsafe') {
$run->update(['review_requested' => true, 'version_no' => $run->version_no + 1]);
}
if ($data['rating'] === 'unsafe') {
DB::table('ai_controls')->where('capability', $run->capability)->update(['suspended' => true, 'updated_at' => now()]);
}
$this->audit('ai_runs', $id, 'feedback_'.$data['rating'], $request->user()->id);
});
return $this->completed($request, 'Feedback registado. Sinalização de risco suspende a capacidade para análise.');
}
public function review(Request $request, string $id, EvidenceRepository $evidence): JsonResponse|RedirectResponse
{
$data = $request->validate(['status' => ['required', 'in:accepted,rejected'], 'version_no' => ['required', 'integer', 'min:1']]);
DB::transaction(function () use ($request, $id, $data, $evidence): void {
$run = AiRun::whereKey($id)->lockForUpdate()->firstOrFail();
abort_if((int) $run->actor_id === $request->user()->id, 403, 'Outra pessoa deve rever o resultado.');
abort_unless((int) $run->version_no === $data['version_no'], 409);
abort_unless($data['status'] === 'rejected' || $this->resultAvailable($run, $evidence), 422, 'Fonte, consentimento ou capacidade já não elegível.');
$run->update(['review_status' => $data['status'], 'reviewed_by' => $request->user()->id,
'reviewed_at' => now(), 'version_no' => $run->version_no + 1]);
$this->audit('ai_runs', $id, 'review_'.$data['status'], $request->user()->id);
});
return $this->completed($request, 'Revisão registada. Nenhum estado oficial, tarefa ou registo CRM foi alterado.');
}
public function control(Request $request, string $capability): JsonResponse|RedirectResponse
{
$data = $request->validate(['suspended' => ['required', 'boolean']]);
abort_unless(in_array($capability, config('investmoz_ai.capabilities'), true), 404);
DB::transaction(function () use ($request, $capability, $data): void {
DB::table('ai_controls')->where('capability', $capability)->update(['suspended' => $data['suspended'], 'updated_at' => now()]);
$this->audit('ai_controls', null, $capability.($data['suspended'] ? '_suspended' : '_resumed'), $request->user()->id);
});
return $this->completed($request, 'Controlo actualizado.');
}
private function completed(Request $request, string $message): JsonResponse|RedirectResponse
{
return $request->expectsJson() ? response()->json(['message' => $message]) : back()->with('success', $message);
}
private function audit(string $entity, ?string $id, string $action, int $actor): void
{
DB::table('audit_events')->insert(['source_system' => 'ai-gateway', 'entity_type' => $entity,
'entity_id' => $id, 'actor_user_id' => $actor, 'action' => $action]);
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Opportunity;
use App\Models\Sector;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\Rule;
class HomeController extends Controller
{
public function show(Request $request): JsonResponse
{
$validated = $request->validate([
'locale' => ['nullable', 'string', Rule::in(['pt', 'en'])],
]);
$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();
$featured = Opportunity::with('sector:id,name,slug')
->published()->where('is_featured', true)
->latest('published_at')->limit(6)->get();
return [
'sectors' => $sectors->map(fn ($s) => [
'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,
'title' => $o->title,
'sector' => $o->sector?->name,
'province' => $o->province,
'maturity' => $o->maturity_label,
'estimatedInvestment' => [
'amount' => $o->estimated_investment_amount,
'currency' => $o->estimated_investment_currency,
],
'url' => "/oportunidades/{$o->slug}",
])->values()->all(),
'stats' => [
'opportunities' => Opportunity::published()->count(),
'sectors' => Sector::where('is_active', true)->count(),
'provinces' => Opportunity::published()->distinct('province')->count('province'),
],
];
});
return response()->json(['success' => true, 'locale' => $locale, 'data' => $data]);
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreInterestRequestRequest;
use App\Jobs\NotifyInterestReceived;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class InterestRequestController extends Controller
{
public function store(StoreInterestRequestRequest $request, InterestSubmission $submission): JsonResponse
{
$validated = $request->validated();
$key = $request->header('Idempotency-Key');
Validator::make(['idempotency_key' => $key], ['idempotency_key' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate();
try {
$interestRequest = $submission->receive($validated, $key, function () use ($request, $validated) {
$opportunity = ! empty($validated['opportunityReference'])
? Opportunity::published()->where('reference', $validated['opportunityReference'])->first()
: null;
if (! empty($validated['opportunityReference']) && ! $opportunity) {
throw ValidationException::withMessages(['opportunityReference' => 'A oportunidade seleccionada já não está disponível.']);
}
$interest = InterestRequest::create([
'opportunity_id' => $opportunity?->id,
'reference' => $this->generateReference(),
'full_name' => trim($validated['fullName']),
'email' => strtolower(trim($validated['email'])),
'phone' => $validated['phone'] ?? null,
'investor_type' => $validated['investorType'],
'country' => $validated['country'],
'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',
'source_page' => Str::limit($request->headers->get('referer', ''), 255, ''),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
if (config('portal.notifications_enabled')) {
$connection = config('queue.connections.database.connection');
if (config('queue.connections.database.after_commit') || ($connection && $connection !== config('database.default'))) {
throw new \RuntimeException('Portal notifications require the transactional database queue.');
}
foreach (array_unique(config('portal.notification_recipients')) as $recipient) {
if (filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
Queue::connection('database')->push(new NotifyInterestReceived($interest->id, $recipient));
}
}
}
return $interest;
});
return response()->json([
'success' => true,
'message' => 'Manifestação de interesse registada com sucesso.',
'data' => [
'reference' => $interestRequest->reference,
'status' => 'received',
],
], 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 (\Throwable $exception) {
Log::error('Erro ao registar manifestação de interesse', [
'exception_type' => $exception::class,
]);
return response()->json([
'success' => false,
'message' => 'Não foi possível registar o pedido. Tente novamente mais tarde.',
], 500);
}
}
private function generateReference(): string
{
$year = now()->format('Y');
return 'IR-'.$year.'-'.Str::ulid();
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Opportunity;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class OpportunityController extends Controller
{
public function search(Request $request): JsonResponse
{
$validated = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'sector' => ['nullable', 'string', 'max:80'],
'province' => ['nullable', 'string', 'max:80'],
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
'max_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999', ...($request->filled('min_amount') ? ['gte:min_amount'] : [])],
'currency' => ['nullable', 'required_with:min_amount,max_amount', 'in:MZN,USD,EUR,ZAR'],
]);
$query = Opportunity::with('sector:id,name,slug')->published();
foreach (['min_amount' => '>=', 'max_amount' => '<='] as $field => $operator) {
if (isset($validated[$field])) {
$query->where('estimated_investment_amount', $operator, $validated[$field]);
}
}
if (! empty($validated['currency'])) {
$query->where('estimated_investment_currency', $validated['currency']);
}
if (! empty($validated['q'])) {
$term = $validated['q'];
$query->where(fn ($q) => $q
->whereLike('title', "%{$term}%")
->orWhereLike('summary', "%{$term}%")
->orWhereLike('reference', "%{$term}%")
->orWhereLike('province', "%{$term}%")
->orWhereHas('sector', fn ($sectorQuery) => $sectorQuery
->whereLike('name', "%{$term}%")
->orWhereLike('slug', "%{$term}%")));
}
if (! empty($validated['sector'])) {
$sector = $validated['sector'];
$query->whereHas('sector', fn ($q) => $q->where('slug', $sector));
}
if (! empty($validated['province'])) {
$query->where('province', $validated['province']);
}
if (! empty($validated['maturity_level'])) {
$query->where('maturity_level', $validated['maturity_level']);
}
$results = $query->orderByDesc('is_featured')
->orderByDesc('published_at')
->paginate($validated['per_page'] ?? 12);
return response()->json($results);
}
public function show(string $slug): JsonResponse
{
$opportunity = Opportunity::with('sector:id,name,slug')
->where('slug', $slug)->published()->firstOrFail();
return response()->json(['success' => true, 'data' => $opportunity]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Sector;
use Illuminate\Http\JsonResponse;
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}",
]);
return response()->json(['success' => true, 'data' => $sectors]);
}
public function show(string $slug): JsonResponse
{
$sector = Sector::with(['opportunities' => fn ($q) => $q->published()])
->where('slug', $slug)->firstOrFail();
return response()->json(['success' => true, 'data' => $sector]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Models\EconomicCluster;
use App\Models\InvestmentGap;
use App\Models\ValueChain;
use App\Models\ValueChainLink;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
class ClusterValueChainController extends Controller
{
public function link(Request $request, EconomicCluster $cluster): RedirectResponse
{
$data = $request->validate([
'from_value_chain_id' => ['required', 'uuid', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'to_value_chain_id' => ['required', 'uuid', 'different:from_value_chain_id', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'link_type' => ['required', Rule::in(['supplies', 'processes', 'distributes', 'supports'])],
]);
DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ValueChainLink::where($data)->first() ?? new ValueChainLink;
PortalAudit::save($link, $data, $request->user()->id);
});
return back()->with('success', 'Ligação entre etapas guardada.');
}
public function removeLink(Request $request, EconomicCluster $cluster, ValueChainLink $link): RedirectResponse
{
DB::transaction(function () use ($cluster, $link, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404);
PortalAudit::save($link, ['deleted_at' => now()], $request->user()->id);
});
return back()->with('success', 'Ligação retirada. O histórico foi preservado.');
}
public function gap(Request $request, EconomicCluster $cluster, ?InvestmentGap $gap = null): RedirectResponse
{
abort_if($gap && $gap->cluster_id !== $cluster->id, 404);
$data = $request->validate([
'value_chain_id' => ['required', 'uuid', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'title' => ['required', 'string', 'max:200'],
'location' => ['required', 'string', 'max:1000'],
'required_capacity' => ['required', 'string', 'max:2000'],
'rationale' => ['required', 'string', 'min:10', 'max:5000'],
'status' => ['required', Rule::in(['identified', 'in_review', 'resolved', 'withdrawn'])],
'call_to_action' => ['required', 'string', 'max:2000'],
]);
PortalAudit::save($gap ?? new InvestmentGap, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Lacuna de investimento guardada.');
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Http\Controllers;
use App\Models\Contact;
use App\Models\CrmTask;
use App\Models\InterestRequest;
use App\Models\Investor;
use App\Models\Lead;
use App\Models\User;
use App\Services\ConvertInterestToLead;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class CrmController extends Controller
{
public function investors(Request $request): Response
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
$query = Investor::query();
if ($term = $filters['q'] ?? null) {
$query->whereLike('legal_name', "%{$term}%");
}
return Inertia::render('crm/investors', ['investors' => $query->orderBy('legal_name')->paginate(20)->withQueryString(), 'filters' => $filters]);
}
public function investor(Investor $investor): Response
{
return Inertia::render('crm/investor', ['investor' => $investor,
'contacts' => Contact::whereIn('id', DB::table('investor_contacts')->where('investor_id', $investor->id)->select('contact_id'))->get(),
'leads' => Lead::where('investor_id', $investor->id)->latest()->get(['id', 'title', 'status'])]);
}
public function saveInvestor(Request $request, Investor $investor): RedirectResponse
{
$data = $request->validate(['legal_name' => ['required', 'string', 'max:200'],
'investor_type' => ['required', Rule::in(['domestic', 'foreign', 'institutional', 'individual'])],
'status' => ['required', Rule::in(['prospect', 'active', 'inactive'])]]);
PortalAudit::save($investor, $data, $request->user()->id);
return back()->with('success', 'Investidor actualizado.');
}
public function saveContact(Request $request, Investor $investor, Contact $contact): RedirectResponse
{
abort_unless(DB::table('investor_contacts')->where('investor_id', $investor->id)->where('contact_id', $contact->id)->exists(), 404);
$data = $request->validate(['full_name' => ['required', 'string', 'max:150'],
'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30']]);
$data['email'] = strtolower(trim($data['email']));
DB::transaction(function () use ($contact, $data, $request) {
DB::select('select pg_advisory_xact_lock(170925, hashtext(?))', [$data['email']]);
if (Contact::where('id', '!=', $contact->id)->whereRaw('lower(trim(email)) = ?', [$data['email']])->exists()) {
throw ValidationException::withMessages(['email' => 'Já existe outro contacto com este email. Reveja os registos antes de os fundir.']);
}
PortalAudit::save($contact, $data, $request->user()->id);
});
return back()->with('success', 'Contacto actualizado.');
}
/** @return Collection<int, User> */
private function owners(): Collection
{
return User::whereNotNull('email_verified_at')->whereIn(DB::raw('lower(email)'), config('portal.managers'))
->orderBy('name')->get(['id', 'name']);
}
public function index(Request $request): Response
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['new', 'qualified', 'disqualified', 'converted'])],
'owner_user_id' => ['nullable', 'integer', 'exists:users,id']]);
$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) {
$query->where(fn ($q) => $q->whereLike('title', "%{$term}%")
->orWhereHas('investor', fn ($investor) => $investor->whereLike('legal_name', "%{$term}%")));
}
if ($filters['status'] ?? null) {
$query->where('status', $filters['status']);
}
if ($filters['owner_user_id'] ?? null) {
$query->where('owner_user_id', $filters['owner_user_id']);
}
return Inertia::render('crm/leads', ['leads' => $query->latest()->orderBy('id')->paginate(20)->withQueryString(),
'filters' => $filters, 'owners' => $this->owners()]);
}
public function prepare(InterestRequest $interest): Response|RedirectResponse
{
$lead = Lead::where('interest_request_id', $interest->id)->first();
if ($lead) {
return redirect('/management/crm/leads/'.$lead->id);
}
return Inertia::render('crm/convert', ['interest' => $interest->only(['id', 'reference', 'full_name', 'sector_interest', 'opportunity_reference']),
'journeyInvestorId' => DB::table('investor_journeys')->where('interest_request_id', $interest->id)->whereNull('deleted_at')->value('investor_id'),
'investors' => Investor::orderBy('legal_name')->get(['id', 'legal_name']), 'owners' => $this->owners()]);
}
public function convert(Request $request, InterestRequest $interest, ConvertInterestToLead $converter): RedirectResponse
{
$data = $request->validate([
'investor_id' => ['nullable', 'uuid', Rule::exists('investors', 'id')->whereNull('deleted_at')],
'legal_name' => ['required_without:investor_id', 'nullable', 'string', 'max:200'],
'investor_type' => ['required_without:investor_id', 'nullable', Rule::in(['domestic', 'foreign', 'institutional', 'individual'])],
'owner_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))],
'title' => ['required', 'string', 'max:200'], 'next_action' => ['required', 'string', 'max:200'],
'due_at' => ['required', 'date', 'after:now'],
]);
$lead = $converter->convert($interest, $data, $request->user()->id);
return redirect('/management/crm/leads/'.$lead->id)->with('success', 'Pedido convertido em lead com responsável e próxima acção.');
}
public function show(Lead $lead): Response
{
$lead->load(['investor', 'owner:id,name', 'tasks' => fn ($q) => $q->orderBy('due_at')]);
$contacts = DB::table('investor_contacts')->join('contacts', 'contacts.id', '=', 'investor_contacts.contact_id')
->where('investor_id', $lead->investor_id)->whereNull('contacts.deleted_at')->get(['contacts.id', 'full_name', 'email', 'phone']);
return Inertia::render('crm/lead', ['lead' => $lead, 'contacts' => $contacts, 'owners' => $this->owners()]);
}
public function update(Request $request, Lead $lead): RedirectResponse
{
$data = $request->validate(['owner_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))],
'status' => ['required', Rule::in(['new', 'qualified', 'disqualified'])],
'qualification_reason' => ['required', 'string', 'min:10', 'max:2000']]);
DB::transaction(function () use ($lead, $data, $request) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
if ($lead->status === 'converted') {
throw ValidationException::withMessages(['status' => 'O lead já foi convertido numa negociação.']);
}
if ($data['status'] !== 'disqualified' && ! $lead->tasks()->whereIn('status', ['open', 'in_progress'])->where('due_at', '>', now())->exists()) {
throw ValidationException::withMessages(['status' => 'Registe uma próxima acção com prazo futuro antes de activar ou qualificar o lead.']);
}
PortalAudit::save($lead, $data, $request->user()->id);
});
return back()->with('success', 'Lead actualizado.');
}
public function task(Request $request, Lead $lead): RedirectResponse
{
$data = $request->validate(['title' => ['required', 'string', 'max:200'], 'due_at' => ['required', 'date', 'after:now'],
'assignee_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))]]);
PortalAudit::save(new CrmTask, [...$data, 'lead_id' => $lead->id, 'status' => 'open'], $request->user()->id);
return back()->with('success', 'Tarefa criada.');
}
public function updateTask(Request $request, Lead $lead, CrmTask $task): RedirectResponse
{
abort_unless($task->lead_id === $lead->id, 404);
$data = $request->validate(['status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])]]);
DB::transaction(function () use ($lead, $task, $data, $request) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
if (in_array($lead->status, ['new', 'qualified'], true) && in_array($data['status'], ['done', 'cancelled'], true)
&& ! $lead->tasks()->where('id', '!=', $task->id)->whereIn('status', ['open', 'in_progress'])->where('due_at', '>', now())->exists()) {
throw ValidationException::withMessages(['status' => 'Crie a próxima acção antes de encerrar a última tarefa de um lead activo.']);
}
PortalAudit::save($task, $data, $request->user()->id);
});
return back()->with('success', 'Tarefa actualizada.');
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __invoke(Request $request): Response
{
if (! $request->user()->can('manage-portal')) {
return Inertia::render('dashboard', ['summary' => null]);
}
$counts = InterestRequest::query()->selectRaw('status, count(*) as total')
->groupBy('status')->pluck('total', 'status');
return Inertia::render('dashboard', ['summary' => [
'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(),
'recentRequests' => InterestRequest::orderByDesc('id')->limit(5)
->get(['id', 'reference', 'sector_interest', 'status', 'created_at']),
]]);
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers;
use App\Models\Course;
use App\Models\EducationInvestmentProposal;
use App\Models\LabourDemand;
use App\Models\Occupation;
use App\Models\Project;
use App\Models\Sector;
use App\Models\Skill;
use App\Models\TrainingCapacity;
use App\Models\TrainingProvider;
use App\Services\EducationGapCalculator;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class EducationController extends Controller
{
public function index(Request $request): Response
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
'demand_page' => ['nullable', 'integer', 'min:1'],
'capacity_page' => ['nullable', 'integer', 'min:1'],
]);
$demands = LabourDemand::query();
$capacities = TrainingCapacity::query();
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);
}
if (($filters['status'] ?? null) === 'expired') {
$query->where('valid_until', '<', now()->toDateString());
} elseif ($status = $filters['status'] ?? null) {
$query->where('status', $status)->where('valid_until', '>=', now()->toDateString());
}
}
return Inertia::render('education/index', [
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
'filters' => $filters,
'indicators' => [
'demand_records' => LabourDemand::count(),
'capacity_records' => TrainingCapacity::count(),
'expired_demands' => LabourDemand::where('valid_until', '<', now()->toDateString())->count(),
'pending_demands' => LabourDemand::where('status', 'submitted')->where('valid_until', '>=', now()->toDateString())->count(),
],
'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']),
'projects' => Project::orderBy('name')->get(['id', 'code', 'name']),
'territories' => DB::table('geo_locations')->whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'code']),
'today' => now()->toDateString(),
]);
}
public function catalog(Request $request, string $kind): RedirectResponse
{
$model = match ($kind) {
'skills' => Skill::class, 'occupations' => Occupation::class,
'providers' => TrainingProvider::class, 'courses' => Course::class,
default => abort(404),
};
$record = new $model;
$rules = [$kind === 'courses' ? 'title' : 'name' => ['required', 'string', 'max:200']];
if ($kind !== 'providers') {
$rules['code'] = ['required', 'string', 'max:80', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')];
}
if ($kind === 'courses') {
$rules['provider_id'] = ['required', 'uuid', Rule::exists('training_providers', 'id')->whereNull('deleted_at')->whereNot('status', 'inactive')];
}
$data = $request->validate($rules);
PortalAudit::save($record, [...$data, ...match ($kind) {
'providers' => ['status' => 'candidate'], 'courses' => ['status' => 'draft'], default => [],
}], $request->user()->id);
return back()->with('success', 'Cadastro interno criado. Não constitui acreditação ou certificação.');
}
public function store(Request $request, string $kind): RedirectResponse
{
$demand = $kind === 'demands';
$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')],
$demand ? 'project_id' : 'course_id' => ['required', 'uuid', $demand
? Rule::exists('projects', 'id')->whereNull('deleted_at')
: Rule::exists('courses', 'id')->whereNull('deleted_at')->whereNot('status', 'retired')
->where(fn ($query) => $query->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id')))],
'employer' => [$demand ? 'required' : 'exclude', 'string', 'max:200'],
'qualification' => ['required', 'string', 'max:150'],
'quantity' => ['required', 'integer', 'min:1', 'max:10000000'],
'period_start' => ['required', 'date_format:Y-m-d'],
'period_end' => ['required', 'date_format:Y-m-d', 'after_or_equal:period_start'],
'source' => ['required', 'string', 'min:10', 'max:5000'],
'reference_date' => ['required', 'date_format:Y-m-d', 'before_or_equal:today'],
'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:reference_date'],
]);
PortalAudit::save($demand ? new LabourDemand : new TrainingCapacity,
[...$data, 'submitted_by' => $request->user()->id, 'status' => 'submitted'], $request->user()->id);
return back()->with('success', 'Declaração registada para revisão independente.');
}
public function review(Request $request, string $kind, string $id): RedirectResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'],
'status' => ['required', 'in:reviewed,rejected'], 'review_reason' => ['required', 'string', 'min:15', 'max:3000']]);
$model = $kind === 'demands' ? LabourDemand::class : TrainingCapacity::class;
DB::transaction(function () use ($model, $id, $data, $request) {
$record = $model::whereKey($id)->lockForUpdate()->firstOrFail();
abort_if((int) $record->getAttribute('submitted_by') === $request->user()->id, 403, 'Outro gestor deve efectuar a revisão.');
abort_if((int) $record->getAttribute('version_no') !== $data['version_no'], 409, 'A versão foi alterada. Recarregue antes de decidir.');
abort_if($record->getAttribute('valid_until') < now()->toDateString(), 422, 'A fonte expirou.');
PortalAudit::save($record, [...$data, 'version_no' => $data['version_no'] + 1,
'reviewed_by' => $request->user()->id], $request->user()->id);
});
return back()->with('success', 'Revisão interna registada; não substitui decisão institucional.');
}
public function analysis(string $id, EducationGapCalculator $calculator): Response
{
$demand = LabourDemand::findOrFail($id);
$current = $demand->getAttribute('valid_until') >= now()->toDateString();
$eligible = $current && $demand->getAttribute('status') === 'reviewed';
$candidates = $eligible ? TrainingCapacity::where('status', 'reviewed')->where('valid_until', '>=', now()->toDateString())
->whereIn('course_id', Course::where('status', '!=', 'retired')
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'))->select('id'))
->where('occupation_id', $demand->getAttribute('occupation_id'))->where('skill_id', $demand->getAttribute('skill_id'))
->where('geo_location_id', $demand->getAttribute('geo_location_id'))->where('qualification', $demand->getAttribute('qualification'))
->where('period_end', '<=', $demand->getAttribute('period_start'))->orderBy('period_end')->get() : collect();
return Inertia::render('education/analysis', ['demand' => $demand, 'candidates' => $candidates,
'proposal' => EducationInvestmentProposal::where('labour_demand_id', $id)->first(),
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
'eligible' => $eligible, 'gap' => $calculator->observed((int) $demand->getAttribute('quantity'), null, $eligible, $current)]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Models\EducationInvestmentProposal;
use App\Models\LabourDemand;
use App\Models\Opportunity;
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;
class EducationInvestmentController extends Controller
{
public function store(Request $request, string $id): RedirectResponse
{
$data = $request->validate([
'demand_version' => ['required', 'integer', 'min:1'],
'sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'title' => ['required', 'string', 'max:200'],
'intervention' => ['required', 'string', 'min:20', 'max:10000'],
'equipment_needs' => ['nullable', 'string', 'max:5000'],
'trainer_needs' => ['nullable', 'string', 'max:5000'],
'funding_plan' => ['nullable', 'string', 'max:5000'],
'risks' => ['required', 'string', 'min:10', 'max:5000'],
'source' => ['required', 'string', 'min:10', 'max:5000'],
'currency' => ['required', Rule::in(['MZN', 'USD', 'EUR', 'ZAR'])],
]);
DB::transaction(function () use ($request, $id, $data): void {
$demand = LabourDemand::whereKey($id)->lockForUpdate()->firstOrFail();
abort_unless((int) $demand->getAttribute('version_no') === $data['demand_version'], 409, 'A procura foi alterada. Recarregue a análise.');
abort_unless($demand->getAttribute('status') === 'reviewed' && $demand->getAttribute('valid_until') >= now()->toDateString(),
422, 'A proposta exige procura revista e fonte vigente.');
// Serialising on the demand and unique FKs make repeated submissions safe.
if (EducationInvestmentProposal::withTrashed()->where('labour_demand_id', $id)->exists()) {
return;
}
$reference = 'EDU-'.Str::uuid();
$opportunity = new Opportunity;
PortalAudit::save($opportunity, [
'sector_id' => $data['sector_id'], 'reference' => $reference,
'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'],
'status' => 'draft', 'is_featured' => false,
], $request->user()->id);
PortalAudit::save(new EducationInvestmentProposal, [
'labour_demand_id' => $id, 'demand_version' => $data['demand_version'],
'opportunity_id' => $opportunity->id, 'created_by' => $request->user()->id,
...array_intersect_key($data, array_flip(['intervention', 'equipment_needs', 'trainer_needs', 'funding_plan', 'risks', 'source'])),
], $request->user()->id);
});
return redirect('/management/education/demands/'.$id.'/analysis')
->with('success', 'Ficha educativa guardada em rascunho. Publicação sujeita a validação institucional.');
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class InvestmentContextController extends Controller
{
public function edit(int $id, InvestmentCatalogue $catalogue): Response
{
return Inertia::render('investment/context', ['record' => Opportunity::findOrFail($id), 'context' => $catalogue->context($id, true), 'references' => $catalogue->references(true)]);
}
public function save(Request $request, int $id): RedirectResponse|JsonResponse
{
$rules = ['version' => ['required', 'integer', 'min:0'], 'is_education' => ['required', 'boolean'],
'business_model' => ['nullable', 'string', 'max:5000'], 'requirements' => ['nullable', 'string', 'max:10000'],
'risks' => ['nullable', 'string', 'max:5000'], 'target_date' => ['nullable', 'date_format:Y-m-d'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'facets' => ['array', 'max:100'],
'facets.*' => ['integer', 'distinct', Rule::exists('investment_reference_options', 'id')->where('active', true)]];
foreach (InvestmentCatalogue::RELATIONS as $relation => [$foreign, $table]) {
$rules[$relation] = ['array', 'max:100'];
$rules[$relation.'.*'] = ['uuid', 'distinct', Rule::exists($table, 'id')->whereNull('deleted_at')];
}
$data = $request->validate($rules);
$version = DB::transaction(function () use ($request, $id, $data) {
$opportunity = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
$profile = DB::table('opportunity_profiles')->where('opportunity_id', $id)->first();
abort_unless((int) ($profile->version ?? 0) === (int) $data['version'], 409, 'Contexto alterado. Recarregue.');
$version = $data['version'] + 1;
DB::table('opportunity_profiles')->updateOrInsert(['opportunity_id' => $id], [
...array_intersect_key($data, array_flip(['is_education', 'business_model', 'requirements', 'risks', 'target_date', 'source_reference'])),
'version' => $version, 'created_at' => $profile->created_at ?? now(), 'updated_at' => now()]);
foreach (InvestmentCatalogue::RELATIONS as $relation => [$foreign, $table]) {
DB::table('opportunity_'.$relation)->where('opportunity_id', $id)->delete();
foreach ($data[$relation] ?? [] as $value) {
DB::table('opportunity_'.$relation)->insert(['opportunity_id' => $id, $foreign => $value]);
}
}
DB::table('opportunity_facets')->where('opportunity_id', $id)->delete();
foreach ($data['facets'] ?? [] as $value) {
DB::table('opportunity_facets')->insert(['opportunity_id' => $id, 'option_id' => $value]);
}
// Editing public context produces a draft for explicit approval, never silent publication.
$opportunity->update(['status' => 'draft', 'published_at' => null]);
$this->audit($request, 'opportunity_context', $id, 'context_saved', ['version' => $version]);
return $version;
});
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Contexto guardado. A ficha está em rascunho para aprovação.');
}
public function references(): Response
{
return Inertia::render('investment/references', ['options' => DB::table('investment_reference_options')->orderBy('group')->orderBy('label')->get()]);
}
public function saveReference(Request $request, ?int $id = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['group' => ['required', 'string', Rule::exists('investment_reference_options', 'group')],
'code' => ['required', 'alpha_dash:ascii', 'max:80'], 'label' => ['required', 'string', 'max:200'],
'active' => ['required', 'boolean'], 'requires_description' => ['required', 'boolean'], 'sort_order' => ['required', 'integer', 'between:0,10000']]);
$savedId = DB::transaction(function () use ($request, $id, $data) {
DB::select('select pg_advisory_xact_lock(270927, hashtext(?))', [$data['group']]);
if ($id) {
$current = DB::table('investment_reference_options')->where('id', $id)->lockForUpdate()->first();
abort_unless($current !== null, 404);
abort_unless($current->group === $data['group'] && $current->code === $data['code'], 422, 'Grupo e código são estáveis para preservar referências.');
abort_if($current->code === 'other' && (! $data['active'] || ! $data['requires_description']), 422, 'Preserve a opção Outro — especificar.');
DB::table('investment_reference_options')->where('id', $id)->update([...$data, 'updated_at' => now()]);
$savedId = $id;
} else {
abort_if(DB::table('investment_reference_options')->where('group', $data['group'])->where('code', $data['code'])->exists(), 422, 'Código já existente neste grupo.');
$savedId = DB::table('investment_reference_options')->insertGetId([...$data, 'created_at' => now(), 'updated_at' => now()]);
}
$this->audit($request, 'investment_reference_options', $savedId, 'reference_saved', ['active' => $data['active']]);
return $savedId;
});
return $request->expectsJson() ? response()->json(['id' => $savedId]) : back()->with('success', 'Opção guardada. Os registos anteriores foram preservados.');
}
/** @param array<string,mixed> $values */
private function audit(Request $request, string $entity, int $id, string $action, array $values): void
{
DB::table('audit_events')->insert(['source_system' => 'investment-context', 'entity_type' => $entity,
'actor_user_id' => $request->user()->id, 'action' => $action,
'after_data' => json_encode(['record_id' => $id, ...$values], JSON_THROW_ON_ERROR)]);
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentZones;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class InvestmentDiscoveryController extends Controller
{
public function index(Request $request, InvestmentCatalogue $catalogue): Response
{
$filters = $catalogue->filters($request);
if ($request->routeIs('investment.education')) {
$filters['education'] = true;
}
return Inertia::render('investment/catalogue', ['filters' => $filters, 'references' => $catalogue->references(),
'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)]);
}
/**
* @param array<string,mixed> $filters
* @return Collection<int,InvestmentDossier>
*/
private function territorial(array $filters): Collection
{
$query = InvestmentPortfolioController::visible();
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
return $query->whereRaw('false')->get();
}
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 (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'].'%');
}
foreach (['cluster_id', 'corridor_id', 'project_id'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
return $query->orderBy('title')->limit(30)->get();
}
public function search(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString());
}
public function references(InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->references());
}
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']]);
$origin = null;
if (! empty($data['zone_id'])) {
$origin = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('zl.zone_id', $data['zone_id'])->where('m.opportunity_id', $record->id)->first(['z.id', 'z.name']);
abort_unless($origin !== null, 404);
}
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin]);
}
public function detailApi(string $slug, InvestmentCatalogue $catalogue): JsonResponse
{
$record = Opportunity::published()->with('sector:id,name,slug')->where('slug', $slug)->firstOrFail();
return response()->json(['record' => $record, 'context' => $catalogue->context($record->id)]);
}
public function suggest(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
$filters = $catalogue->filters($request);
if (mb_strlen(trim($filters['q'] ?? '')) < 2) {
return response()->json(['opportunities' => [], 'territorial' => []]);
}
return response()->json(['opportunities' => $catalogue->query($filters)->limit(5)->get(['id', 'title', 'slug']), 'territorial' => $this->territorial($filters)->take(5)->values()]);
}
public function map(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->map($catalogue->filters($request)));
}
public function compare(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
$data = $request->validate(['ids' => ['required', 'array', 'min:1', 'max:4'], 'ids.*' => ['required', 'integer', 'distinct']]);
$records = Opportunity::published()->with('sector:id,name,slug')->whereIn('id', $data['ids'])->get();
abort_unless($records->count() === count($data['ids']), 404);
return response()->json(['records' => $records->map(fn ($r) => ['record' => $r, 'context' => $catalogue->context($r->id)])]);
}
public function favourite(Request $request, int $id): JsonResponse
{
Opportunity::published()->findOrFail($id);
$data = $request->validate(['saved' => ['required', 'boolean']]);
$key = ['user_id' => $request->user()->id, 'opportunity_id' => $id];
if ($data['saved']) {
DB::table('opportunity_favourites')->insertOrIgnore([...$key, 'created_at' => now(), 'updated_at' => now()]);
} else {
DB::table('opportunity_favourites')->where($key)->delete();
}
return response()->json(['saved' => $data['saved']]);
}
public function export(Request $request, InvestmentCatalogue $catalogue): StreamedResponse
{
$records = $catalogue->query($catalogue->filters($request))->limit(1000)->get();
return response()->streamDownload(function () use ($records) {
$stream = fopen('php://output', 'w');
if ($stream === false) {
return;
}
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, ['Referência', 'Título', 'Sector', 'Província', 'Montante', 'Moeda', 'Maturidade', 'URL'], ';', '"', '');
foreach ($records as $r) {
$fields = [$r->reference, $r->title, $r->sector?->name, $r->province, $r->estimated_investment_amount, $r->estimated_investment_currency, $r->maturity_label, url('/oportunidades/'.$r->slug)];
fputcsv($stream, array_map(fn ($v) => preg_match('/^[\s]*[=+@-]/u', (string) $v) ? "'".$v : $v, $fields), ';', '"', '');
}
fclose($stream);
}, 'oportunidades.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'X-Export-Limit' => '1000']);
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentLegalSource;
use App\Services\InvestmentZoneAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class InvestmentLegalSourceController extends Controller
{
public function index(): JsonResponse
{
return response()->json(InvestmentLegalSource::orderByDesc('created_at')->paginate(20));
}
public function store(Request $request): JsonResponse
{
$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']]);
return DB::transaction(function () use ($request, $data) {
$source = InvestmentLegalSource::create([...$data, 'owner_id' => $request->user()->id]);
$source->refresh();
$this->audit($source, $request->user()->id, 'created');
return response()->json($source->fresh(), 201);
});
}
public function transition(Request $request, string $id, string $action): JsonResponse
{
if ($action === 'validate') {
InvestmentZoneAccess::requireRole($request->user(), 'legal_reviewers');
}
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return DB::transaction(function () use ($request, $id, $action, $data) {
$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((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.');
}
$source->fill(['validation_status' => $action === 'validate' ? 'validated' : 'revoked',
'version_no' => (int) $source->version_no + 1,
'validator_id' => $action === 'validate' ? $request->user()->id : $source->validator_id,
'validated_at' => $action === 'validate' ? now() : $source->validated_at])->save();
$this->audit($source, $request->user()->id, $action);
return response()->json($source);
});
}
private function audit(InvestmentLegalSource $source, int $actor, string $action): void
{
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_legal_sources',
'entity_id' => $source->getKey(), 'actor_user_id' => $actor, 'action' => $action,
'after_data' => json_encode(['version_no' => $source->version_no, 'validation_status' => $source->validation_status,
'sha256' => hash('sha256', $source->toJson())], JSON_THROW_ON_ERROR)]);
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Services\InvestmentZones;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class InvestmentPortfolioController extends Controller
{
public static function related(InvestmentDossier $first, InvestmentDossier $second): bool
{
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()) {
return true;
}
}
}
return false;
}
/** @return Builder<InvestmentDossier> */
public static function visible(): Builder
{
return InvestmentDossier::select(['id', 'title', 'content', 'source', 'project_id', 'cluster_id', 'corridor_id', 'publication_status', 'published_at'])->where('publication_status', 'published')->where(function ($q) {
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) {
$q->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from($table)
->whereColumn($table.'.id', 'investment_dossiers.'.$foreign)->whereNull($table.'.deleted_at'));
}
});
}
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']]);
$query = $management ? InvestmentDossier::query() : self::visible();
if (! empty($filters['kind'])) {
$query->whereNotNull($filters['kind'].'_id');
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
}
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
'management' => $management, 'filters' => $filters]);
}
public function show(Request $request, string $id): Response
{
$management = $request->routeIs('management.portfolio*');
$record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id);
$relatedIds = [];
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
foreach ([[$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());
}
}
}
$related = ($management ? InvestmentDossier::query() : self::visible())->where(function ($q) use ($relatedIds) {
$q->whereRaw('false');
foreach ($relatedIds as $column => $ids) {
$q->orWhereIn($column, $ids);
}
})->orderBy('title')->get(['id', 'title', 'project_id', 'cluster_id', 'corridor_id']);
$context = $request->validate(['from' => ['nullable', 'uuid']]);
$origin = isset($context['from']) ? self::visible()->whereKey($context['from'])->firstOrFail() : null;
abort_if($origin && ! self::related($record, $origin), 422, 'Contexto sem relação com esta ficha.');
$opportunities = Opportunity::published()->where(function ($query) use ($record) {
$query->whereRaw('false');
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'));
}
}
})->orderBy('title')->get(['id', 'title', 'slug', 'summary']);
$chains = $record->cluster_id ? DB::table('value_chains')->where('cluster_id', $record->cluster_id)->whereNull('deleted_at')
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->orderBy('name')->get(['id', 'name', 'stage']) : collect();
$zones = collect();
foreach (['cluster_id', 'corridor_id', 'project_id'] as $foreign) {
if ($record->$foreign) {
$zones = $zones->merge(app(InvestmentZones::class)->links(true)->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')
->where('zl.'.$foreign, $record->$foreign)->get(['z.id', 'z.name', 'z.category', 'zl.association_type', 'zl.source_reference']));
}
}
return Inertia::render('portfolio/show', ['record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
}
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'],
'content' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'],
'content.*' => ['nullable', 'string', 'max:15000']]);
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 guardar.');
PortalAudit::save($dossier, [...$data, 'version_no' => $dossier->version_no + 1, 'editor_id' => $request->user()->id, 'reviewer_id' => null,
'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.');
}
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]);
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.');
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);
});
return back()->with('success', 'Ficha publicada com evidência de revisão.');
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers;
use App\Models\ClusterProject;
use App\Models\EconomicCluster;
use App\Models\InvestmentGap;
use App\Models\Project;
use App\Models\ValueChain;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
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;
$query = $model::query();
if ($term = $filters['q'] ?? null) {
$query->where(function ($q) use ($term): void {
$q->whereLike('name', "%{$term}%")->orWhereLike('code', "%{$term}%");
});
}
return Inertia::render('structures/index', ['structure' => $structure, 'filters' => $filters,
'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
}
public function edit(string $structure, ?string $id = null): Response
{
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
$record = $id ? $model::findOrFail($id) : null;
return Inertia::render('structures/editor', ['structure' => $structure, 'record' => $record,
'projects' => $structure === 'clusters' && $id ? Project::orderBy('name')->get(['id', 'name', 'code']) : [],
'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->get() : [],
'gaps' => $structure === 'clusters' && $id ? InvestmentGap::where('cluster_id', $id)->orderBy('created_at')->get() : [],
'chainLinks' => $structure === 'clusters' && $id ? DB::table('value_chain_links')
->join('value_chains as origins', 'origins.id', '=', 'value_chain_links.from_value_chain_id')
->join('value_chains as destinations', 'destinations.id', '=', 'value_chain_links.to_value_chain_id')
->where('origins.cluster_id', $id)->where('destinations.cluster_id', $id)
->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']) : [],
'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']) : [],
]);
}
public function save(Request $request, string $structure, ?string $id = null): RedirectResponse
{
$isProject = $structure === 'projects';
$model = $isProject ? Project::class : EconomicCluster::class;
$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'])],
];
if ($isProject) {
$rules += ['description' => ['nullable', 'string', 'max:10000'],
'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 {
$rules['sector'] = ['required', 'string', 'max:200'];
}
PortalAudit::save($record, $request->validate($rules), $request->user()->id);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
}
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'])]]);
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);
});
return back()->with('success', 'Associação ao projecto guardada.');
}
public function saveChain(Request $request, EconomicCluster $cluster, ?ValueChain $chain = null): RedirectResponse
{
abort_if($chain && $chain->cluster_id !== $cluster->id, 404);
$data = $request->validate(['code' => ['required', 'string', 'max:100',
Rule::unique('value_chains', 'code')->where('cluster_id', $cluster->id)->whereNull('deleted_at')->ignore($chain?->id)],
'name' => ['required', 'string', 'max:200'],
'stage' => ['required', Rule::in(['input', 'production', 'processing', 'logistics', 'market'])]]);
PortalAudit::save($chain ?? new ValueChain, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Etapa da cadeia de valor guardada.');
}
}
@@ -0,0 +1,336 @@
<?php
namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\InvestmentSpecificNeed;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Services\InterestSubmission;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentWorkspace;
use App\Services\InvestmentZones;
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\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
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) {
$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,
'reference' => $interest?->reference, 'request_status' => $interest?->status];
});
$data = ['submissions' => $submissions,
'favourites' => Opportunity::published()->whereIn('id', DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->select('opportunity_id'))->get(['id', 'title', 'slug'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/workspace', $data);
}
public function create(Request $request, InvestmentCatalogue $catalogue): RedirectResponse|JsonResponse
{
$data = $request->validate(['opportunity_id' => ['nullable', 'integer'], 'dossier_id' => ['nullable', 'uuid'], 'company_id' => ['nullable', 'uuid'],
'zone_id' => ['nullable', 'uuid', 'prohibits:dossier_id,company_id'], 'intent' => ['nullable', 'required_with:zone_id', 'in:interest,dossier,meeting']]);
abort_if(! empty($data['opportunity_id']) && ! empty($data['dossier_id']), 422);
$payload = [...app(InvestmentWorkspace::class)->account($request->user()->id)['payload'], 'full_name' => $request->user()->name, 'email' => $request->user()->email];
if (! empty($data['company_id'])) {
$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']];
}
unset($data['company_id']);
if (! empty($data['opportunity_id'])) {
$opportunity = Opportunity::published()->with('sector')->where('id', $data['opportunity_id'])->firstOrFail();
$payload['sector'] = $opportunity->sector?->name;
$payload['location'] = implode(', ', array_filter([$opportunity->district, $opportunity->province]));
$context = $catalogue->context($opportunity->id);
foreach (['clusters' => 'cluster_ids', 'corridors' => 'corridor_ids', 'projects' => 'project_ids'] as $relation => $field) {
$payload[$field] = $context[$relation]->pluck('id')->all();
}
}
if (! empty($data['dossier_id'])) {
$dossier = InvestmentPortfolioController::visible()->where('id', $data['dossier_id'])->firstOrFail();
foreach (['cluster_id' => 'cluster_ids', 'corridor_id' => 'corridor_ids', 'project_id' => 'project_ids'] as $foreign => $field) {
if ($dossier->$foreign) {
$payload[$field] = [$dossier->$foreign];
}
}
}
$zoneId = $data['zone_id'] ?? null;
$intent = $data['intent'] ?? 'interest';
unset($data['zone_id'], $data['intent']);
$submission = DB::transaction(function () use ($request, $data, $payload, $zoneId, $intent) {
$zone = null;
if ($zoneId) {
$zones = app(InvestmentZones::class);
$zone = $zones->visible()->whereKey($zoneId)->lockForUpdate()->firstOrFail();
if (! empty($data['opportunity_id'])) {
abort_unless($zones->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
->where('zl.zone_id', $zoneId)->where('m.opportunity_id', $data['opportunity_id'])->exists(), 422, 'O investimento não tem associação pública à zona seleccionada.');
}
// Serializes draft creation for this investor without a global lock.
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->first();
$draft = InvestmentSubmission::where('user_id', $request->user()->id)->where('status', 'draft')
->where('opportunity_id', $data['opportunity_id'] ?? null)
->whereIn('id', DB::table('investment_submission_zones')->where('zone_id', $zoneId)->where('zone_version', $zone->version_no)->where('intent', $intent)->select('submission_id'))->first();
if ($draft) {
return $draft;
}
}
$row = InvestmentSubmission::create([...$data, 'user_id' => $request->user()->id,
'payload' => $payload, 'status' => 'draft', 'version' => 1]);
if ($zone) {
DB::table('investment_submission_zones')->insert(['submission_id' => $row->id, 'zone_id' => $zone->id, 'zone_version' => $zone->version_no, 'intent' => $intent]);
}
$this->event($row, $request, 'draft_created');
return $row;
});
return $request->expectsJson() ? response()->json(['id' => $submission->id, 'version' => $submission->version], 201)
: redirect('/investidor/manifestacoes/'.$submission->id);
}
public function show(Request $request, string $id, InvestmentCatalogue $catalogue): Response|JsonResponse
{
$row = $this->owned($request, $id);
$interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null;
$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' => [
...$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']),
'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];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/submission', $data);
}
private function owned(Request $request, string $id, bool $lock = false, bool $ownerOnly = false): InvestmentSubmission
{
$query = InvestmentSubmission::whereKey($id);
if ($ownerOnly || ! $request->user()->can('manage-portal')) {
$query->where('user_id', $request->user()->id);
}
return ($lock ? $query->lockForUpdate() : $query)->firstOrFail();
}
/** @return array<string,mixed> */
private function validated(Request $request, bool $submitting): array
{
$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.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.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'],
'payload.objective' => [$required, 'string', ...($submitting ? ['min:20'] : []), 'max:5000'], 'payload.participation_id' => [$required, 'integer'],
'payload.partnership_id' => ['nullable', 'integer'], 'payload.estimated_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
'payload.currency' => ['nullable', 'required_with:payload.estimated_amount', 'regex:/^[A-Z]{3}$/'],
'payload.horizon' => [$required, 'string', 'max:150'], 'payload.location' => ['nullable', 'string', 'max:500'],
'payload.cluster_ids' => ['nullable', 'array', 'max:20'], 'payload.cluster_ids.*' => ['uuid', 'distinct'],
'payload.corridor_ids' => ['nullable', 'array', 'max:20'], 'payload.corridor_ids.*' => ['uuid', 'distinct'],
'payload.project_ids' => ['nullable', 'array', 'max:30'], 'payload.project_ids.*' => ['uuid', 'distinct'],
'payload.contact_consent' => $submitting ? ['accepted'] : ['nullable', 'boolean'],
'needs' => ['nullable', 'array', 'max:40'], 'needs.*' => ['array:category_id,option_id,unit_id,priority_id,quantity,deadline,details'],
'needs.*.category_id' => ['required', 'integer'], 'needs.*.option_id' => ['required', 'integer'],
'needs.*.unit_id' => ['nullable', 'integer'], 'needs.*.priority_id' => ['nullable', 'integer'],
'needs.*.quantity' => ['nullable', 'numeric', 'min:0', 'max:999999999999'], 'needs.*.deadline' => ['nullable', 'date_format:Y-m-d'],
'needs.*.details' => ['required', 'array:description,location,observations,qualification,specialty,experience,certifications,availability,institution,training_mode,duration,status'],
'needs.*.details.*' => ['nullable', 'string', 'max:2000'], 'needs.*.details.description' => ['required', 'string', 'min:3', 'max:2000']]);
foreach (['profile_id' => 'investor_profile', 'participation_id' => 'participation', 'partnership_id' => 'partnership'] as $field => $group) {
if (! empty($data['payload'][$field])) {
$option = $this->option($data['payload'][$field], $group, 'payload.'.$field);
if ($option->requires_description && mb_strlen(trim($data['payload']['other_details'] ?? '')) < 3) {
throw ValidationException::withMessages(['payload.other_details' => 'Especifique as opções assinaladas como Outro.']);
}
}
}
foreach (['cluster_ids' => 'cluster_id', 'corridor_ids' => 'corridor_id', 'project_ids' => 'project_id'] as $field => $column) {
$ids = $data['payload'][$field] ?? [];
if ($ids && InvestmentPortfolioController::visible()->whereIn($column, $ids)->count() !== count($ids)) {
throw ValidationException::withMessages(['payload.'.$field => 'Uma referência territorial já não está publicada.']);
}
}
foreach ($data['needs'] ?? [] as $index => $need) {
$category = $this->option($need['category_id'], 'need_category', 'needs.'.$index.'.category_id');
$group = match ($category->code) {
'labour_required', 'labour_available' => 'labour',
'infrastructure_existing', 'infrastructure_required' => 'infrastructure',
'education' => 'education', default => 'support',
};
$this->option($need['option_id'], $group, 'needs.'.$index.'.option_id');
foreach (['unit_id' => 'unit', 'priority_id' => 'priority'] as $field => $expected) {
if (! empty($need[$field])) {
$this->option($need[$field], $expected, 'needs.'.$index.'.'.$field);
}
}
}
return $data;
}
private function option(int $id, string $group, string $field): \stdClass
{
$option = DB::table('investment_reference_options')->where('id', $id)->where('group', $group)->where('active', true)->first();
if (! $option) {
throw ValidationException::withMessages([$field => 'Seleccione uma opção activa desta categoria.']);
}
return $option;
}
public function save(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $this->validated($request, false);
$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]);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
}
$this->event($row, $request, 'draft_saved');
return $row->version;
});
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Rascunho guardado.');
}
public function submit(Request $request, string $id, InterestSubmission $intake): RedirectResponse|JsonResponse
{
// Replaying a completed submission returns only the owner's existing receipt.
$existing = $this->owned($request, $id, false, true);
if ($existing->status === 'submitted') {
$reference = InterestRequest::findOrFail($existing->interest_request_id)->reference;
} else {
$data = $this->validated($request, true);
$reference = DB::transaction(function () use ($request, $id, $data, $intake) {
$row = $this->owned($request, $id, true, true);
if ($row->status === 'submitted') {
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;
if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
}
$p = $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();
abort_unless((int) $zone->version_no === (int) $zoneOrigin->zone_version, 409, 'A ficha da zona mudou. Consulte a versão actual antes de preparar novo pedido.');
if ($opportunity) {
abort_unless(app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
->where('zl.zone_id', $zoneOrigin->zone_id)->where('m.opportunity_id', $opportunity->id)->exists(), 422, 'A associação à zona deixou de estar disponível.');
}
}
$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',
'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,
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]]));
$row->update(['payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
}
$this->event($row, $request, 'submitted');
app(InvestmentWorkspace::class)->notify($row->user_id, $row, 'received', 'received:'.$row->id, ['reference' => $interest->reference]);
return $interest->reference;
});
}
$status = InterestRequest::where('reference', $reference)->value('status');
return $request->expectsJson() ? response()->json(['reference' => $reference, 'status' => $status]) : back()->with('success', 'Manifestação recebida: '.$reference);
}
public function attach(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer'], 'file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240']]);
$file = $request->file('file');
$documentId = (string) Str::uuid();
$key = null;
try {
$version = DB::transaction(function () use ($request, $id, $data, $file, $documentId, &$key) {
$row = $this->owned($request, $id, true, true);
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409);
abort_if(DB::table('investment_submission_attachments')->where('submission_id', $id)->count() >= 10, 422, 'Máximo de dez anexos por pedido.');
$key = $file->storeAs('investment-submissions/'.$id, $documentId, 'local');
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']);
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');
return $row->version;
});
} catch (\Throwable $e) {
if (is_string($key)) {
Storage::disk('local')->delete($key);
}
throw $e;
}
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Anexo privado guardado.');
}
public function download(Request $request, string $id, string $attachment): BinaryFileResponse
{
$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']);
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);
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
{
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)]);
}
}
@@ -0,0 +1,159 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentSubmission;
use App\Models\User;
use App\Services\InvestmentWorkspace;
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\Str;
use Inertia\Inertia;
use Inertia\Response;
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')
->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);
return $company;
})];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data);
}
public function saveProfile(Request $request): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
'payload' => ['required', 'array:phone,country,sector,experience,investment_capacity'],
'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']]);
$version = DB::transaction(function () use ($request, $data) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail();
$row = DB::table('investment_accounts')->where('user_id', $request->user()->id)->first();
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Perfil alterado. Recarregue.');
$version = (int) $data['version'] + 1;
DB::table('investment_accounts')->updateOrInsert(['user_id' => $request->user()->id], ['version' => $version,
'payload' => Crypt::encryptString(json_encode($data['payload'], JSON_THROW_ON_ERROR)), 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($request, 'profile_saved', 'investment_accounts', null, ['version' => $version]);
return $version;
});
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Perfil de investimento guardado.');
}
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.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) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail();
$row = $id ? DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->first() : null;
abort_if($id && ! $row, 404);
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Declaração alterada. Recarregue.');
abort_if(! $id && DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->count() >= 20, 422, 'Máximo de vinte empresas declaradas.');
$id ??= (string) Str::uuid();
$version = (int) $data['version'] + 1;
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]);
return ['id' => $id, 'version' => $version];
});
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Declaração de empresa guardada.');
}
public function notifications(Request $request): Response|JsonResponse
{
$query = DB::table('investment_notifications')->where('user_id', $request->user()->id);
if (! $request->user()->can('manage-portal')) {
$query->whereIn('submission_id', InvestmentSubmission::where('user_id', $request->user()->id)->select('id'));
}
$data = ['unread' => (clone $query)->whereNull('read_at')->count(), 'notifications' => $query->orderByDesc('created_at')->paginate(20)->through(function ($row) {
$row->payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
return $row;
})];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/notifications', $data);
}
public function readNotification(Request $request, string $id): RedirectResponse|JsonResponse
{
$row = DB::table('investment_notifications')->where('id', $id)->where('user_id', $request->user()->id)->first();
abort_unless($row !== null, 404);
$this->submission($request, $row->submission_id);
DB::table('investment_notifications')->where('id', $id)->whereNull('read_at')->update(['read_at' => now()]);
return $request->expectsJson() ? response()->json(['read' => true]) : back();
}
public function message(Request $request, string $id, InvestmentWorkspace $workspace): RedirectResponse|JsonResponse
{
$data = $request->validate(['client_key' => ['required', 'uuid'], 'body' => ['required', 'string', 'min:3', 'max:5000']]);
$messageId = DB::transaction(function () use ($request, $id, $data, $workspace) {
$submission = $this->submission($request, $id, true);
abort_unless($submission->status === 'submitted', 409, 'Submeta a manifestação antes de enviar mensagens.');
$key = ['submission_id' => $id, 'actor_id' => $request->user()->id, 'client_key' => $data['client_key']];
$existing = DB::table('investment_messages')->where($key)->first();
if ($existing) {
abort_unless(hash_equals(Crypt::decryptString($existing->body), $data['body']), 409, 'Chave já usada para outra mensagem.');
return $existing->id;
}
$isOwner = (int) $submission->user_id === $request->user()->id;
$messageId = (string) Str::uuid();
DB::table('investment_messages')->insert([...$key, 'id' => $messageId, 'sender_role' => $isOwner ? 'investor' : 'support',
'body' => Crypt::encryptString($data['body']), 'created_at' => now()]);
$recipients = $isOwner ? User::whereIn(DB::raw('lower(email)'), config('portal.managers', []))->whereNotNull('email_verified_at')->pluck('id')->all() : [$submission->user_id];
foreach ($recipients as $recipient) {
if ((int) $recipient !== $request->user()->id) {
$workspace->notify((int) $recipient, $submission, 'message', $messageId);
}
}
$this->audit($request, 'message_sent', 'investment_messages', $messageId);
return $messageId;
});
return $request->expectsJson() ? response()->json(['id' => $messageId]) : back()->with('success', 'Mensagem registada na conversa.');
}
public function revision(Request $request, string $id, int $version): JsonResponse
{
$this->submission($request, $id);
$revision = DB::table('investment_submission_revisions')->where('submission_id', $id)->where('version', $version)->first();
abort_unless($revision !== null, 404);
return response()->json(['version' => $version, 'snapshot' => json_decode(Crypt::decryptString($revision->snapshot), true, 512, JSON_THROW_ON_ERROR)]);
}
private function submission(Request $request, string $id, bool $lock = false): InvestmentSubmission
{
$query = InvestmentSubmission::whereKey($id);
if (! $request->user()->can('manage-portal')) {
$query->where('user_id', $request->user()->id);
}
return ($lock ? $query->lockForUpdate() : $query)->firstOrFail();
}
/** @param array<string,mixed> $metadata */
private function audit(Request $request, string $action, string $entity, ?string $id, array $metadata = []): void
{
DB::table('audit_events')->insert(['source_system' => 'investment-workspace', 'actor_user_id' => $request->user()->id,
'entity_type' => $entity, 'entity_id' => $id, 'action' => $action, 'after_data' => json_encode($metadata, JSON_THROW_ON_ERROR)]);
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class InvestmentZoneController extends Controller
{
public function show(Request $request, InvestmentZones $zones, string $id): Response|JsonResponse
{
$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']);
$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;
$opportunities = $field && ! $management ? app(InvestmentCatalogue::class)->query([$field => $id])->paginate(12)->withQueryString() : null;
$related = collect();
foreach (['cluster_id', 'corridor_id', 'project_id'] as $foreign) {
$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,
'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() : [],
'targets' => $management ? ['opportunities' => Opportunity::orderBy('title')->limit(200)->get(['id', 'title']),
'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() : [],
'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() : [],
'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);
}
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']]);
$management = $request->is('management/*', 'api/v2/investment/admin/*');
$query = $management ? InvestmentZone::query() : $zones->visible();
$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];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
}
public function save(Request $request, InvestmentZones $zones, ?string $id = null): JsonResponse
{
$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']]);
return response()->json($zones->save($id, $data, $request->user()->id), $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));
}
public function revisions(string $id): JsonResponse
{
InvestmentZone::findOrFail($id);
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']));
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentZone;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class InvestmentZoneGeometryController extends Controller
{
public function store(Request $request, string $id): JsonResponse
{
$data = $request->validate(['geometry' => ['required', 'array:type,coordinates'],
'geometry.type' => ['required', 'in:Point,LineString,Polygon,MultiPolygon'], 'geometry.coordinates' => ['required', 'array'],
'purpose' => ['required', 'in:official_perimeter,influence,site,alternative'],
'legal_source_id' => ['nullable', 'required_if:purpose,official_perimeter', 'uuid', 'exists:investment_legal_sources,id'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'validating_entity' => ['required', 'string', 'max:255'],
'observed_at' => ['required', 'date_format:Y-m-d', 'before_or_equal:today']]);
$json = json_encode($data['geometry'], JSON_THROW_ON_ERROR);
if (strlen($json) > 200000) {
throw ValidationException::withMessages(['geometry' => 'Máximo 200 KB por geometria.']);
}
if ($data['purpose'] === 'official_perimeter' && ! in_array($data['geometry']['type'], ['Polygon', 'MultiPolygon'], true)) {
throw ValidationException::withMessages(['geometry' => 'O perímetro exige polígono ou multipolígono.']);
}
try {
$valid = DB::transaction(fn () => DB::selectOne('SELECT ST_IsValid(g) AND NOT ST_IsEmpty(g) AND ST_NDims(g)=2 AND ST_XMin(Box3D(g))>=-180 AND ST_XMax(Box3D(g))<=180 AND ST_YMin(Box3D(g))>=-90 AND ST_YMax(Box3D(g))<=90 AS valid FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON(?),4326) g) s', [$json]));
} catch (QueryException) {
throw ValidationException::withMessages(['geometry' => 'GeoJSON inválido.']);
}
if (! $valid->valid) {
throw ValidationException::withMessages(['geometry' => 'Geometria inválida, vazia ou fora do SRID 4326.']);
}
return DB::transaction(function () use ($request, $id, $data, $json) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$location = (string) Str::uuid();
$geometry = (string) Str::uuid();
$link = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $location, 'name' => $zone->name, 'code' => 'zone:'.$location, 'location_type' => 'site']);
$type = ['Point' => 'point', 'LineString' => 'line', 'Polygon' => 'polygon', 'MultiPolygon' => 'multipolygon'][$data['geometry']['type']];
DB::insert('INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom) VALUES (?,?,?,ST_SetSRID(ST_GeomFromGeoJSON(?),4326))', [$geometry, $location, $type, $json]);
unset($data['geometry']);
DB::table('investment_zone_geometries')->insert([...$data, 'id' => $link, 'zone_id' => $id, 'geometry_id' => $geometry,
'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($link, $request->user()->id, 'created', 1);
return response()->json(['id' => $link, 'version_no' => 1, 'validation_status' => 'pending'], 201);
});
}
public function review(Request $request, string $id, string $action): JsonResponse
{
if ($action === 'validate') {
InvestmentZoneAccess::requireRole($request->user(), 'gis_reviewers');
}
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return DB::transaction(function () use ($request, $id, $action, $data) {
// Serializes perimeter approvals, including approval of overlapping zones.
DB::select("SELECT pg_advisory_xact_lock(hashtext('investment-zone-perimeters'))");
$link = DB::table('investment_zone_geometries')->where('id', $id)->lockForUpdate()->first();
abort_unless($link !== null, 404);
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
if ($action === 'validate') {
abort_if((int) $link->editor_id === $request->user()->id, 403, 'Revisão GIS independente obrigatória.');
abort_unless($link->validation_status === 'pending', 422, 'Geometria já analisada.');
if ($link->legal_source_id) {
$source = DB::table('investment_legal_sources')->where('id', $link->legal_source_id)->lockForUpdate()->first();
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte válida em falta.');
}
if ($link->purpose === 'official_perimeter') {
$conflict = DB::table('investment_zone_geometries as other')->join('geo_geometries as a', 'a.id', '=', 'other.geometry_id')
->join('geo_geometries as b', fn ($j) => $j->where('b.id', '=', $link->geometry_id))
->where('other.id', '<>', $id)->where('other.publication_authorized', true)->where('other.purpose', 'official_perimeter')
->whereRaw('ST_Intersects(a.geom,b.geom) AND NOT ST_Touches(a.geom,b.geom)')->exists();
abort_if($conflict, 422, 'Sobreposição de perímetros: remeter para validação territorial.');
}
}
DB::table('investment_zone_geometries')->where('id', $id)->update(['validation_status' => $action === 'validate' ? 'validated' : 'revoked',
'publication_authorized' => $action === 'validate', 'reviewer_id' => $request->user()->id, 'version_no' => $link->version_no + 1, 'updated_at' => now()]);
if ($action === 'validate') {
// The reviewed shape is public only through the zone map, not generic location search.
DB::table('geo_geometries')->where('id', $link->geometry_id)->update(['data_classification' => 'public']);
}
$this->audit($id, $request->user()->id, $action, $link->version_no + 1);
return response()->json(['id' => $id, 'version_no' => $link->version_no + 1]);
});
}
public function map(Request $request, InvestmentZones $zones): JsonResponse
{
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric']]);
$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')
->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())
->where(fn ($s) => $s->whereNull('ls.valid_until')->orWhereDate('ls.valid_until', '>=', today()))));
if (isset($data['bbox'])) {
[$west, $south, $east, $north] = $data['bbox'];
abort_unless($west >= -180 && $east <= 180 && $south >= -90 && $north <= 90 && $west < $east && $south < $north, 422, 'Janela geográfica inválida.');
$query->whereRaw('ST_Intersects(g.geom,ST_MakeEnvelope(?,?,?,?,4326))', [$west, $south, $east, $north]);
}
$rows = $query->orderBy('l.id')->limit(101)->get(['l.id', 'l.zone_id', 'l.purpose', 'l.source_reference', 'l.observed_at', DB::raw('ST_AsGeoJSON(ST_SimplifyPreserveTopology(g.geom,0.0001),6) AS geometry')]);
return response()->json(['type' => 'FeatureCollection', 'limit' => 100, 'truncated' => $rows->count() > 100,
'features' => $rows->take(100)->map(fn ($r) => ['type' => 'Feature', 'id' => $r->id, 'geometry' => json_decode($r->geometry, true),
'properties' => ['zone_id' => $r->zone_id, 'purpose' => $r->purpose, 'source' => $r->source_reference, 'observed_at' => $r->observed_at]])->values()]);
}
private function audit(string $id, int $actor, string $action, int $version): void
{
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zone_geometries', 'entity_id' => $id,
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode(['version_no' => $version], JSON_THROW_ON_ERROR)]);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Services\InvestmentIdentity;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class InvestmentZoneLinkController extends Controller
{
public function store(Request $request, string $id): JsonResponse
{
$data = $request->validate(['opportunity_id' => ['required', 'integer', Rule::exists('opportunities', 'id')->whereNull('deleted_at')],
'association_type' => ['required', 'in:official,influence,proximity,potential,historical'],
'role' => ['required', 'in:primary,beneficiary,served,enabled,dependent,related,potential,historical'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'justification' => ['required', 'string', 'min:10', 'max:3000'],
'legal_source_id' => ['nullable', 'required_if:association_type,official', 'uuid', 'exists:investment_legal_sources,id'],
'valid_from' => ['required', 'date_format:Y-m-d'], 'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'priority' => ['sometimes', 'integer', 'between:0,100'], 'is_primary' => ['sometimes', 'boolean']]);
return DB::transaction(function () use ($request, $id, $data) {
InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$opportunity = Opportunity::whereKey($data['opportunity_id'])->lockForUpdate()->firstOrFail();
$itemId = app(InvestmentIdentity::class)->forOpportunity($opportunity->id);
abort_if(DB::table('investment_zone_links')->where('zone_id', $id)->where('item_id', $itemId)->exists(), 409, 'Associação já registada.');
unset($data['opportunity_id']);
$linkId = (string) Str::uuid();
DB::table('investment_zone_links')->insert([...$data, 'id' => $linkId, 'item_id' => $itemId, 'zone_id' => $id,
'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($linkId, $request->user()->id, 'created', 1);
return response()->json(DB::table('investment_zone_links')->where('id', $linkId)->first(), 201);
});
}
public function transition(Request $request, string $id, string $action): JsonResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return DB::transaction(function () use ($request, $id, $action, $data) {
$link = DB::table('investment_zone_links')->where('id', $id)->lockForUpdate()->first();
abort_unless($link !== null, 404);
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
if ($action === 'validate') {
abort_if((int) $link->editor_id === $request->user()->id, 403, 'Outra pessoa deve validar a associação.');
abort_unless($link->validation_status === 'pending', 422, 'A associação já foi analisada.');
if ($link->legal_source_id) {
$source = DB::table('investment_legal_sources')->where('id', $link->legal_source_id)->lockForUpdate()->first();
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte jurídica inválida.');
}
}
DB::table('investment_zone_links')->where('id', $id)->update(['version_no' => $link->version_no + 1,
'validation_status' => $action === 'validate' ? 'validated' : 'revoked',
'validator_id' => $action === 'validate' ? $request->user()->id : $link->validator_id, 'updated_at' => now()]);
$this->audit($id, $request->user()->id, $action, $link->version_no + 1);
return response()->json(DB::table('investment_zone_links')->where('id', $id)->first());
});
}
private function audit(string $id, int $actor, string $action, int $version): void
{
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zone_links',
'entity_id' => $id, 'actor_user_id' => $actor, 'action' => $action,
'after_data' => json_encode(['version_no' => $version], JSON_THROW_ON_ERROR)]);
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers;
use App\Models\InvestmentZone;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class InvestmentZoneRelationController extends Controller
{
public function store(Request $request, string $id): JsonResponse
{
$data = $request->validate(['cluster_id' => ['nullable', 'required_without_all:corridor_id,project_id', 'prohibits:corridor_id,project_id', 'uuid', Rule::exists('economic_clusters', 'id')->whereNull('deleted_at')],
'corridor_id' => ['nullable', 'required_without_all:cluster_id,project_id', 'prohibits:cluster_id,project_id', 'uuid', Rule::exists('development_corridors', 'id')->whereNull('deleted_at')],
'project_id' => ['nullable', 'required_without_all:cluster_id,corridor_id', 'prohibits:cluster_id,corridor_id', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'association_type' => ['required', 'in:official,influence,proximity,potential,historical'],
'role' => ['required', 'in:primary,beneficiary,served,enabled,dependent,related,potential,historical'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'], 'justification' => ['required', 'string', 'min:10', 'max:3000'],
'legal_source_id' => ['nullable', 'required_if:association_type,official', 'uuid', 'exists:investment_legal_sources,id'],
'valid_from' => ['required', 'date_format:Y-m-d'], 'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
'priority' => ['sometimes', 'integer', 'between:0,100'], 'is_primary' => ['sometimes', 'boolean']]);
return DB::transaction(function () use ($request, $id, $data) {
InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$query = DB::table('investment_zone_relations')->where('zone_id', $id);
foreach (['cluster_id', 'corridor_id', 'project_id'] as $field) {
if (! empty($data[$field])) {
$query->where($field, $data[$field]);
}
}
abort_if($query->exists(), 409, 'Relação já registada.');
$relation = (string) Str::uuid();
DB::table('investment_zone_relations')->insert([...$data, 'id' => $relation, 'zone_id' => $id,
'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($relation, $request->user()->id, 'created', 1);
return response()->json(DB::table('investment_zone_relations')->where('id', $relation)->first(), 201);
});
}
public function review(Request $request, string $id, string $action): JsonResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
return DB::transaction(function () use ($request, $id, $action, $data) {
$relation = DB::table('investment_zone_relations')->where('id', $id)->lockForUpdate()->first();
abort_unless($relation !== null, 404);
abort_if((int) $relation->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
if ($action === 'validate') {
abort_if((int) $relation->editor_id === $request->user()->id, 403, 'A relação exige revisão independente.');
abort_unless($relation->validation_status === 'pending', 422, 'Relação já analisada.');
if ($relation->legal_source_id) {
$source = DB::table('investment_legal_sources')->where('id', $relation->legal_source_id)->lockForUpdate()->first();
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte válida em falta.');
}
}
DB::table('investment_zone_relations')->where('id', $id)->update(['validation_status' => $action === 'validate' ? 'validated' : 'revoked',
'validator_id' => $action === 'validate' ? $request->user()->id : $relation->validator_id,
'version_no' => $relation->version_no + 1, 'updated_at' => now()]);
$this->audit($id, $request->user()->id, $action, $relation->version_no + 1);
return response()->json(DB::table('investment_zone_relations')->where('id', $id)->first());
});
}
private function audit(string $id, int $actor, string $action, int $version): void
{
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zone_relations', 'entity_id' => $id,
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode(['version_no' => $version], JSON_THROW_ON_ERROR)]);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Investor;
use App\Models\InvestorJourney;
use App\Services\InterestSubmission;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class InvestorWorkspaceController extends Controller
{
public function index(Request $request): Response
{
$journeys = InvestorJourney::where('user_id', $request->user()->id)->latest()->get()->map(function ($journey) {
$dossier = InvestmentPortfolioController::visible()->find($journey->dossier_id);
$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];
});
return Inertia::render('portfolio/workspace', ['journeys' => $journeys]);
}
public function start(Request $request, string $id): RedirectResponse
{
$dossier = InvestmentPortfolioController::visible()->findOrFail($id);
$data = $request->validate(['origin_id' => ['nullable', 'uuid']]);
$origin = ! empty($data['origin_id']) ? InvestmentPortfolioController::visible()->whereKey($data['origin_id'])->firstOrFail() : null;
abort_if($origin && ! InvestmentPortfolioController::related($dossier, $origin), 422);
DB::transaction(function () use ($request, $dossier, $origin) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->first();
$account = DB::table('investor_accounts')->where('user_id', $request->user()->id)->first();
$investorId = $account?->investor_id;
if (! $account) {
$investor = Investor::create(['legal_name' => $request->user()->name, 'investor_type' => 'individual',
'status' => 'prospect', 'source_system' => 'portal-account', 'data_classification' => 'confidential']);
DB::table('investor_accounts')->insert(['user_id' => $request->user()->id, 'investor_id' => $investor->id, 'created_at' => now(), 'updated_at' => now()]);
$investorId = $investor->id;
}
$journey = InvestorJourney::firstOrNew(['user_id' => $request->user()->id, 'dossier_id' => $dossier->id]);
if (! $journey->exists) {
PortalAudit::save($journey, ['investor_id' => $investorId, 'status' => 'started', 'origin_dossier_id' => $origin?->id,
'source_system' => 'portal-account', 'data_classification' => 'confidential'], $request->user()->id);
}
});
return redirect('/investidor')->with('success', 'Guardado no seu painel.');
}
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'],
'privacy_consent' => ['accepted'], 'role' => ['required', 'in:investir,financiar,fornecer,operar,parceria'],
'horizon' => ['required', 'string', 'max:100']]);
DB::transaction(function () use ($request, $id, $data, $submission) {
$journey = InvestorJourney::where('user_id', $request->user()->id)->whereKey($id)->lockForUpdate()->firstOrFail();
if ($journey->interest_request_id) {
return; // One expression per saved journey; retry returns its original protocol.
}
$dossier = InvestmentPortfolioController::visible()->whereKey($journey->dossier_id)->lockForUpdate()->firstOrFail();
$origin = $journey->origin_dossier_id ? InvestmentPortfolioController::visible()->find($journey->origin_dossier_id) : null;
$interest = $submission->receive(['email' => $request->user()->email, 'fullName' => $request->user()->name, 'message' => $data['message']], null,
fn () => InterestRequest::create([
'reference' => 'IR-'.now()->year.'-'.Str::ulid(), 'full_name' => $request->user()->name,
'email' => $request->user()->email, 'investor_type' => 'individual', 'country' => $data['country'],
'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,
'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'],
]));
PortalAudit::save($journey, ['interest_request_id' => $interest->id, 'status' => 'active'], $request->user()->id);
});
return back()->with('success', 'Manifestação recebida. Consulte o protocolo no painel.');
}
}
@@ -0,0 +1,198 @@
<?php
namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentWorkspace;
use App\Services\PortalAudit;
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\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PortalManagementController extends Controller
{
private const STATES = ['received', 'in_review', 'contacted', 'closed'];
public function history(): Response
{
$events = DB::table('audit_events')
->leftJoin('users', 'users.id', '=', 'audit_events.actor_user_id')
->whereIn('audit_events.source_system', ['portal-management', 'investment-context', 'investment-submission', 'investment-workspace', 'investment-zones'])
->orderByDesc('audit_events.occurred_at')->orderByDesc('audit_events.id')
->select(['audit_events.id', 'audit_events.entity_id', 'entity_type', 'action', 'occurred_at', 'before_data', 'after_data', 'users.name as actor'])
->paginate(20)->through(function ($event) {
$before = json_decode($event->before_data ?? '{}', true) ?? [];
$after = json_decode($event->after_data ?? '{}', true) ?? [];
$values = $after['values'] ?? array_intersect_key($after, array_flip(['version', 'status', 'active']));
$event->before_data = ['values' => $before['values'] ?? []];
$event->after_data = ['record_id' => $after['record_id'] ?? $event->entity_id,
'changed_fields' => $after['changed_fields'] ?? array_keys($values), 'values' => $values];
return $event;
});
return Inertia::render('management/history', ['events' => $events]);
}
public function interests(Request $request): Response
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(self::STATES)],
]);
$query = InterestRequest::query()->select([
'id', 'reference', 'full_name', 'email', 'sector_interest', 'status', 'created_at',
]);
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
->orWhereLike('full_name', "%{$term}%")->orWhereLike('email', "%{$term}%"));
}
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
return Inertia::render('management/interests', [
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
'filters' => $filters,
]);
}
public function interest(InterestRequest $interest): Response
{
return Inertia::render('management/interest', [
'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',
]),
]);
}
public function review(Request $request, InterestRequest $interest): 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']]);
}
});
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'status' => $validated['status']]) : back()->with('success', 'Estado do pedido actualizado.');
}
public function catalog(Request $request, string $resource): Response
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$filters = $request->validate([
'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')],
'page' => ['nullable', 'integer', 'min:1'],
]);
$query = $model::query();
if ($term = trim($filters['q'] ?? '')) {
$query->where(function ($query) use ($term, $resource) {
$query->whereLike($resource === 'sectors' ? 'name' : 'title', "%{$term}%")
->orWhereLike('slug', "%{$term}%");
if ($resource === 'opportunities') {
$query->orWhereLike('reference', "%{$term}%");
}
});
}
if ($status = $filters['status'] ?? null) {
if ($resource === 'sectors') {
$query->where('is_active', $status === 'active');
} elseif ($status === 'published') {
$query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now());
} else {
$query->where('status', $status);
}
}
if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) {
$query->where('sector_id', $filters['sector_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']) : [],
]);
}
public function edit(string $resource, ?int $id = null): Response
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
return Inertia::render('management/editor', [
'resource' => $resource,
'record' => $id ? $model::findOrFail($id) : null,
'sectors' => Sector::orderBy('name')->get(['id', 'name']),
]);
}
public function save(Request $request, string $resource, ?int $id = null): RedirectResponse
{
$model = $resource === 'sectors' ? Sector::class : Opportunity::class;
$record = $id ? $model::findOrFail($id) : new $model;
$rules = $resource === 'sectors' ? [
'name' => ['required', 'string', 'max:150'],
'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)],
'short_description' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:20000'],
'sort_order' => ['required', 'integer', 'min:0', 'max:2147483647'],
'is_active' => ['required', 'boolean'],
] : [
'sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
'reference' => ['required', 'string', 'max:80', Rule::unique('opportunities')->ignore($record->id)],
'title' => ['required', 'string', 'max:200'],
'slug' => ['required', 'alpha_dash:ascii', 'max:220', Rule::unique('opportunities')->ignore($record->id)],
'summary' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:50000'],
'province' => ['nullable', 'string', 'max:100'],
'district' => ['nullable', 'string', 'max:100'],
'promoter_name' => ['nullable', 'string', 'max:200'],
'promoter_type' => ['nullable', 'string', 'max:100'],
'maturity_level' => ['nullable', 'integer', 'between:1,6'],
'estimated_investment_amount' => ['nullable', 'regex:/^\d{1,16}(\.\d{1,2})?$/'],
'estimated_investment_currency' => ['required', 'regex:/^[A-Z]{3}$/'],
'financing_modality' => ['nullable', 'string', 'max:120'],
'expected_impact' => ['nullable', 'string', 'max:10000'],
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['required', 'boolean'],
];
$data = $request->validate($rules);
if ($resource === 'opportunities' && $data['status'] === 'published' && $record->exists
&& Schema::hasTable('education_investment_proposals')
&& DB::table('education_investment_proposals')->where('opportunity_id', $record->id)->exists()) {
throw ValidationException::withMessages([
'status' => 'Esta proposta educativa aguarda validação institucional da lacuna e da resposta formativa. A revisão interna não autoriza publicação.',
]);
}
if ($resource === 'opportunities') {
$labels = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso',
'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação'];
$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);
return redirect('/management/'.$resource)->with('success', 'Registo guardado.');
}
}
@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Sector;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
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;
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'])]);
}
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');
}
public function page(Request $request): Response
{
$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 === 'sectors') {
$props['sectors'] = Sector::where('is_active', true)->withCount(['opportunities' => fn ($q) => $q->published()])->orderBy('sort_order')->get(['id', 'name', 'slug', 'short_description']);
}
if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources();
if ($page === 'legislation') {
$query->where('r.legislation', true);
}
$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) {
if (! empty($filters[$field])) {
$query->where('r.'.$field, $filters[$field]);
}
}
if (! empty($filters['q'])) {
$query->where('r.title', 'ilike', '%'.$filters['q'].'%');
}
$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');
foreach (['sector', 'province'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
}
$props['partnerships'] = $query->orderByDesc('id')->select(['id', 'title', 'sector', 'province', 'offer', 'seeking'])->paginate(20)->withQueryString();
}
if ($page === 'desk') {
$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']);
}
$props['contacts'] = config('portal.support');
return Inertia::render('portal/services', $props);
}
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'],
'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'],
'partnership_id' => ['nullable', 'integer'],
]);
if (! empty($data['partnership_id'])) {
abort_unless(DB::table('portal_partnerships')->where('id', $data['partnership_id'])->where('status', 'published')->exists(), 422);
}
$data['portal_user_id'] = $request->user()?->id;
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',
'metadata' => ['kind' => $data['kind'], 'company' => $data['company'] ?? null,
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null],
]));
} catch (IdempotencyConflict) {
abort(409, 'Este envio já foi usado com dados diferentes. Recarregue o formulário.');
}
return back()->with('success', 'Pedido registado. Protocolo: '.$interest->reference);
}
public function partnership(Request $request): RedirectResponse
{
$data = $request->validate(['title' => ['required', 'string', 'max:200'], 'sector' => ['required', 'string', 'max:150'],
'province' => ['required', 'string', 'max:100'], 'offer' => ['required', 'string', 'min:20', 'max:2000'],
'seeking' => ['required', 'string', 'min:20', 'max:2000'], 'publication_consent' => ['accepted']]);
unset($data['publication_consent']);
DB::table('portal_partnerships')->insert([...$data, 'user_id' => $request->user()->id, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
return back()->with('success', 'Proposta recebida para publicação. Acompanhe no Balcão Virtual.');
}
public function download(int $id): BinaryFileResponse
{
$resource = $this->resources()->where('r.id', $id)->first(['d.storage_key', 'd.file_name', 'd.sha256']);
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']);
}
public function manage(): Response
{
return Inertia::render('portal/manage', ['resources' => DB::table('portal_resources')->latest()->limit(100)->get(),
'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'])]);
}
public function upload(Request $request): RedirectResponse
{
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240'], 'publication_consent' => ['accepted']]);
$file = $request->file('file');
$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::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']);
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']);
});
} 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.');
}
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);
});
return back()->with('success', 'Documento catalogado.');
}
public function moderate(Request $request, string $type, int $id): RedirectResponse
{
$data = $request->validate(['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);
DB::table($table)->where('id', $id)->update($type === 'resources'
? ['published' => $data['published'], 'approved_by' => $request->user()->id, 'updated_at' => now()]
: ['status' => $data['published'] ? 'published' : 'withdrawn', 'reviewed_by' => $request->user()->id, 'updated_at' => now()]);
$this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id);
});
return back()->with('success', 'Publicação actualizada.');
}
private function audit(Request $request, string $action, string $table, int $id): 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)]);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Models\Sector;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PublicPortalController extends Controller
{
public function opportunities(Request $request): Response
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80']]);
return Inertia::render('PortalInvestidor', ['initialView' => 'opportunities', 'initialQuery' => $filters['q'] ?? '', 'initialSector' => $filters['sector'] ?? '']);
}
public function sector(string $slug): Response
{
$sector = Sector::where('slug', $slug)->where('is_active', true)->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'])]);
}
public function opportunity(string $slug): Response
{
return $this->detail($slug, 'detail');
}
public function interest(?string $slug = null): Response
{
return $slug ? $this->detail($slug, 'interest') : Inertia::render('PortalInvestidor', ['initialView' => 'interest']);
}
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]);
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileDeleteRequest;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
return to_route('profile.edit');
}
/**
* Delete the user's profile.
*/
public function destroy(ProfileDeleteRequest $request): RedirectResponse
{
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\PasswordUpdateRequest;
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
class SecurityController extends Controller
{
/**
* Show the user's security settings page.
*/
public function edit(TwoFactorAuthenticationRequest $request): Response
{
$props = [
/* @chisel-2fa */
'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(),
/* @end-chisel-2fa */
/* @chisel-passkeys */
'canManagePasskeys' => Features::canManagePasskeys(),
'passkeys' => Features::canManagePasskeys()
? $request->user()
->passkeys()
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
->latest()
->get()
->map(fn ($passkey) => [
'id' => $passkey->id,
'name' => $passkey->name,
'authenticator' => $passkey->authenticator,
'created_at_diff' => $passkey->created_at->diffForHumans(),
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
])
->values()
->all()
: [],
/* @end-chisel-passkeys */
'passwordRules' => Password::defaults()->toPasswordRulesString(),
];
/* @chisel-2fa */
if (Features::canManageTwoFactorAuthentication()) {
$request->ensureStateIsValid();
$props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication();
$props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
/* @end-chisel-2fa */
return Inertia::render('settings/security', $props);
}
/**
* Update the user's password.
*/
public function update(PasswordUpdateRequest $request): RedirectResponse
{
$request->user()->update([
'password' => $request->password,
]);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]);
return back();
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class AiEnabled
{
public function handle(Request $request, Closure $next): Response
{
abort_unless(config('investmoz_ai.enabled'), 404);
return $next($request);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EducationEnabled
{
public function handle(Request $request, Closure $next): Response
{
abort_unless(config('education.enabled'), 404);
return $next($request);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
return [
...parent::share($request),
'name' => config('app.name'),
'educationEnabled' => (bool) config('education.enabled', false),
'aiEnabled' => (bool) config('investmoz_ai.enabled', false),
'flash' => ['success' => fn () => $request->session()->get('success')],
'auth' => [
'user' => $request->user(),
'canManagePortal' => $request->user()?->can('manage-portal') ?? false,
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class PrivateInvestmentResponse
{
/** @param Closure(Request): Response $next */
public function handle(Request $request, Closure $next): Response
{
Inertia::encryptHistory();
$response = $next($request);
$response->headers->set('Cache-Control', 'private, no-store');
return $response;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireZoneMfa
{
/** @param Closure(Request): Response $next */
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403, 'Configure e confirme a autenticação de dois factores para gerir zonas.');
return $next($request);
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class PasswordUpdateRequest extends FormRequest
{
use PasswordValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProfileDeleteRequest extends FormRequest
{
use PasswordValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'password' => $this->currentPasswordRules(),
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Settings;
use App\Concerns\ProfileValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProfileUpdateRequest extends FormRequest
{
use ProfileValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return $this->profileRules($this->user()->id);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Fortify\InteractsWithTwoFactorState;
class TwoFactorAuthenticationRequest extends FormRequest
{
use InteractsWithTwoFactorState;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class StoreInterestRequestRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
$key = $this->header('Idempotency-Key');
$knownKey = is_string($key) && Str::isUuid($key)
&& DB::table('interest_submission_receipts')->where('key_hash', hash('sha256', strtolower($key)))->exists();
return [
'fullName' => ['required', 'string', 'min:3', 'max:150'],
'email' => ['required', 'email:rfc,dns', 'max:150'],
'phone' => ['nullable', 'string', 'max:30'],
'investorType' => ['required', 'string', Rule::in([
'individual', 'empresa', 'fundo', 'entidade_publica', 'outro',
])],
'country' => ['required', 'string', 'max:100'],
'sectorInterest' => ['required', 'string', 'max:150'],
// A known receipt can replay after withdrawal; the service verifies the payload hash.
'opportunityReference' => ['nullable', 'string', 'max:80', ...($knownKey ? [] : [
Rule::exists('opportunities', 'reference')->where(fn ($query) => $query
->where('status', 'published')->whereNotNull('published_at')
->where('published_at', '<=', now())->whereNull('deleted_at')),
])],
'investmentRange' => ['nullable', 'string', 'max:80'],
'message' => ['required', 'string', 'min:20', 'max:2000'],
'privacyConsent' => ['required', 'accepted'],
];
}
public function messages(): array
{
return [
'opportunityReference.exists' => 'A oportunidade seleccionada já não está disponível.',
'fullName.required' => 'O nome completo é obrigatório.',
'email.required' => 'O email é obrigatório.',
'email.email' => 'Indique um email válido.',
'investorType.required' => 'Seleccione o tipo de investidor.',
'country.required' => 'O país é obrigatório.',
'sectorInterest.required' => 'O sector de interesse é obrigatório.',
'message.required' => 'A mensagem é obrigatória.',
'message.min' => 'A mensagem deve ter pelo menos 20 caracteres.',
'privacyConsent.accepted' => 'É necessário aceitar a política de privacidade.',
];
}
}
@@ -0,0 +1,320 @@
<?php
namespace App\Integrations\SuiteCrm;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
use SensitiveParameter;
final class SuiteCrmClient
{
/** @return array<string, mixed> */
public function find(string $module, string $id): array
{
$this->module($module);
$this->identifier($id);
return $this->request('GET', '/module/'.$module.'/'.$id, [], 'find');
}
/**
* @param array<string, string> $equals Exact-match filters; no arbitrary URLs.
* @return array<string, mixed>
*/
public function search(string $module, #[SensitiveParameter] array $equals, int $page = 1, int $size = 20): array
{
$this->module($module);
if ($page < 1 || $size < 1 || $size > 100) {
throw new SuiteCrmException('invalid_pagination');
}
$filter = ['operator' => 'and'];
foreach ($equals as $field => $value) {
$this->identifier($field);
if ($field === 'operator') {
throw new SuiteCrmException('invalid_filter');
}
$filter[$field] = ['eq' => $value];
}
return $this->request('GET', '/module/'.$module, ['filter' => $filter, 'page' => ['number' => $page, 'size' => $size]], 'search');
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function create(string $module, #[SensitiveParameter] array $attributes): array
{
$this->module($module);
return $this->request('POST', '/module', ['data' => ['type' => $module, 'attributes' => $attributes]], 'create');
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function update(string $module, string $id, #[SensitiveParameter] array $attributes): array
{
$this->module($module);
$this->identifier($id);
return $this->request('PATCH', '/module', ['data' => ['type' => $module, 'id' => $id, 'attributes' => $attributes]], 'update');
}
/** @return array<string, mixed> */
public function relate(string $module, string $id, string $linkField, string $relatedModule, string $relatedId): array
{
$this->module($module);
$this->module($relatedModule);
foreach ([$id, $linkField, $relatedId] as $identifier) {
$this->identifier($identifier);
}
return $this->request('POST', '/module/'.$module.'/'.$id.'/relationships/'.$linkField,
['data' => ['type' => $relatedModule, 'id' => $relatedId]], 'relate');
}
/** @return array<string, mixed> */
public function fields(string $module): array
{
$this->module($module);
return $this->request('GET', '/meta/fields/'.$module, [], 'fields');
}
private function module(string $module): void
{
$this->identifier($module);
if (! in_array($module, config()->array('suitecrm.modules'), true)) {
throw new SuiteCrmException('module_not_allowed');
}
}
private function identifier(string $value): void
{
if (! preg_match('/\A[A-Za-z0-9_][A-Za-z0-9_-]{0,99}\z/', $value)) {
throw new SuiteCrmException('invalid_identifier');
}
}
private function baseUrl(): string
{
if (! config('suitecrm.enabled')) {
throw new SuiteCrmException('disabled');
}
$url = config()->string('suitecrm.base_url');
$parts = parse_url($url);
if (! $parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])
|| isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])
|| ! filter_var($url, FILTER_VALIDATE_URL)
|| preg_match('/[\s\\\\]/', $url)) {
throw new SuiteCrmException('invalid_base_url');
}
if (config()->string('suitecrm.client_id') === '' || config()->string('suitecrm.client_secret') === '') {
throw new SuiteCrmException('missing_credentials');
}
return rtrim($url, '/');
}
private function cacheKey(string $base): string
{
// Rotation separates both token and breaker state without exposing the secret.
return 'suitecrm:'.hash('sha256', $base.'|'.config()->string('suitecrm.client_id').'|'.config()->string('suitecrm.client_secret'));
}
private function token(string $base, string $key): string
{
$cache = Cache::store(config()->string('suitecrm.cache_store'));
try {
return $this->lock($key.':token-lock', 20)->block(3, function () use ($cache, $base, $key): string {
$encrypted = $cache->get($key.':token');
if (is_string($encrypted)) {
try {
return Crypt::decryptString($encrypted);
} catch (DecryptException) {
$cache->forget($key.':token');
}
}
$response = $this->send('POST', $base.'/Api/access_token', [
'grant_type' => 'client_credentials',
'client_id' => config()->string('suitecrm.client_id'),
'client_secret' => config()->string('suitecrm.client_secret'),
], null, 'authenticate');
if (! $response->successful()) {
throw $this->failure($response, false);
}
$data = $response->json();
if (! is_array($data) || ! is_string($data['access_token'] ?? null) || $data['access_token'] === ''
|| ! is_numeric($data['expires_in'] ?? null) || (int) $data['expires_in'] < 2
|| ! is_string($data['token_type'] ?? null) || strtolower($data['token_type']) !== 'bearer') {
throw new SuiteCrmException('invalid_token_response');
}
$seconds = (int) $data['expires_in'];
$cache->put($key.':token', Crypt::encryptString($data['access_token']), max(1, $seconds - min(60, (int) ceil($seconds / 10))));
return $data['access_token'];
});
} catch (LockTimeoutException) {
throw new SuiteCrmException('authentication_busy', retryable: true);
}
}
private function lock(string $name, int $seconds): Lock
{
$store = Cache::store(config()->string('suitecrm.cache_store'))->getStore();
if (! $store instanceof LockProvider) {
throw new SuiteCrmException('cache_locks_required');
}
return $store->lock($name, $seconds);
}
private function invalidateToken(string $key, #[SensitiveParameter] string $rejected): void
{
$cache = Cache::store(config()->string('suitecrm.cache_store'));
try {
$this->lock($key.':token-lock', 20)->block(3, function () use ($cache, $key, $rejected): void {
$encrypted = $cache->get($key.':token');
if (! is_string($encrypted)) {
return;
}
try {
// A concurrent request may already have renewed this token.
if (hash_equals(Crypt::decryptString($encrypted), $rejected)) {
$cache->forget($key.':token');
}
} catch (DecryptException) {
$cache->forget($key.':token');
}
});
} catch (LockTimeoutException) {
throw new SuiteCrmException('authentication_busy', retryable: true);
}
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function request(string $method, string $path, #[SensitiveParameter] array $data, string $operation): array
{
$base = $this->baseUrl();
$key = $this->cacheKey($base);
$cache = Cache::store(config()->string('suitecrm.cache_store'));
if ($cache->has($key.':open')) {
throw new SuiteCrmException('circuit_open', retryable: true);
}
$read = $method === 'GET';
$limit = $read ? max(0, min(3, config()->integer('suitecrm.read_retries'))) : 0;
$renewed = false;
$attempt = 0;
try {
while (true) {
try {
$token = $this->token($base, $key);
$response = $this->send($method, $base.'/Api/V8'.$path, $data, $token, $operation);
if ($response->status() === 401 && ! $renewed) {
$this->invalidateToken($key, $token);
$renewed = true;
continue;
}
if (! $response->successful()) {
throw $this->failure($response, ! $read);
}
$result = $response->json();
if ($response->status() === 204) {
$result = [];
}
if (! is_array($result) || ! empty($result['errors'])) {
throw new SuiteCrmException('invalid_response', outcomeUnknown: ! $read);
}
$cache->forget($key.':failures');
return $result;
} catch (SuiteCrmException $e) {
// Never replay writes after ambiguous transport/server failures.
if (! $read || ! $e->retryable || $attempt >= $limit || ($e->retryAfter ?? 0) > 5) {
throw $e;
}
$delay = max(($e->retryAfter ?? 0) * 1000,
min(4000, max(0, config()->integer('suitecrm.retry_delay_ms')) * (2 ** $attempt)));
if ($delay > 0) {
Sleep::usleep(($delay + random_int(0, 100)) * 1000);
}
$attempt++;
}
}
} catch (SuiteCrmException $e) {
if ($e->retryable || $e->reason === 'invalid_response') {
try {
$this->lock($key.':circuit-lock', 5)->block(1, function () use ($cache, $key): void {
$failures = (int) $cache->get($key.':failures', 0) + 1;
$cooldown = max(5, config()->integer('suitecrm.circuit_cooldown'));
$cache->put($key.':failures', $failures, $cooldown);
if ($failures >= max(1, config()->integer('suitecrm.circuit_threshold'))) {
$cache->put($key.':open', true, $cooldown);
}
});
} catch (LockTimeoutException) {
// Preserve the sanitised operation failure if bookkeeping is busy.
}
}
throw $e;
}
}
/** @param array<string, mixed> $data */
private function send(string $method, string $url, #[SensitiveParameter] array $data, #[SensitiveParameter] ?string $token, string $operation): Response
{
$started = hrtime(true);
$status = null;
try {
$request = Http::connectTimeout(max(1, min(5, config()->integer('suitecrm.connect_timeout'))))
->timeout(max(1, min(10, config()->integer('suitecrm.timeout'))))
->withoutRedirecting()->withOptions(['verify' => true])->asJson()
->accept($token === null ? 'application/json' : 'application/vnd.api+json');
if ($token !== null) {
$request = $request->withToken($token)->contentType('application/vnd.api+json');
}
$response = $request->send($method, $url, [$method === 'GET' ? 'query' : 'json' => $data]);
$status = $response->status();
return $response;
} catch (ConnectionException) {
throw new SuiteCrmException('transport_failure', retryable: true, outcomeUnknown: $token !== null && $method !== 'GET');
} finally {
// Bounded dimensions; no URL, identifiers, attributes, bodies or headers.
Log::info('suitecrm.http', ['operation' => $operation, 'status' => $status,
'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000)]);
}
}
private function failure(Response $response, bool $write): SuiteCrmException
{
$status = $response->status();
$retryable = $status === 429 || $status >= 500;
$retryAfter = null;
$header = $response->header('Retry-After');
if ($header !== '') {
$timestamp = strtotime($header);
$retryAfter = ctype_digit($header) ? min(86400, (int) $header) : ($timestamp === false ? null : max(0, min(86400, $timestamp - time())));
}
return new SuiteCrmException(match ($status) {
401 => 'unauthenticated', 403 => 'forbidden', 404 => 'not_found', 409 => 'conflict',
422 => 'validation_failed', 429 => 'rate_limited', default => $status >= 500 ? 'unavailable' : 'unexpected_status',
}, $status, $retryable, $write && $status >= 500, $retryAfter);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Integrations\SuiteCrm;
use RuntimeException;
final class SuiteCrmException extends RuntimeException
{
public function __construct(
public readonly string $reason,
public readonly ?int $httpStatus = null,
public readonly bool $retryable = false,
public readonly bool $outcomeUnknown = false,
public readonly ?int $retryAfter = null,
) {
// Never attach the original HTTP exception: it can contain credentials/PII.
parent::__construct('SuiteCRM integration: '.$reason);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Jobs;
use App\Models\InterestRequest;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
class NotifyInterestReceived implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public int $interestId, public string $recipient) {}
/** @return list<int> */
public function backoff(): array
{
return [60, 300, 900];
}
public function handle(): void
{
if (! config('portal.notifications_enabled') || ! in_array($this->recipient, config('portal.notification_recipients'), true)) {
return;
}
$interest = InterestRequest::find($this->interestId);
if (! $interest) {
return;
}
$body = "Foi recebida uma manifestação de interesse: {$interest->reference}.\n\nConsulte o pedido na área protegida:\n".url('/management/interests/'.$interest->id);
Mail::raw($body, fn ($message) => $message->to($this->recipient)->subject('Novo pedido de interesse — '.$interest->reference));
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class AiRun extends Model
{
use HasUuids;
protected $guarded = ['id'];
protected $casts = ['result' => 'encrypted:array', 'source_ids' => 'array', 'review_requested' => 'boolean'];
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class ClusterProject extends DomainRecord {}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class Contact extends CrmRecord {}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class Course extends DomainRecord {}
+6
View File
@@ -0,0 +1,6 @@
<?php
namespace App\Models;
/** @property string $id */
abstract class CrmRecord extends DomainRecord {}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Models;
/** @property string|null $lead_id */
class CrmTask extends CrmRecord
{
protected $table = 'tasks';
protected $casts = ['due_at' => 'immutable_datetime'];
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class DevelopmentCorridor extends DomainRecord {}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
/** @property string $id */
abstract class DomainRecord extends Model
{
use HasUuids, SoftDeletes;
protected $guarded = ['id'];
protected $keyType = 'string';
public $incrementing = false;
public function newUniqueId(): string
{
return (string) Str::uuid();
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class EconomicCluster extends DomainRecord {}
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class EducationInvestmentProposal extends DomainRecord {}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class InterestRequest extends Model
{
use SoftDeletes;
protected $fillable = [
'opportunity_id', 'reference', 'full_name', 'email', 'phone',
'investor_type', 'country', 'sector_interest',
'opportunity_reference', 'investment_range', 'message',
'privacy_consent', 'consented_at', 'status', 'source_page',
'ip_address', 'user_agent', 'metadata', 'reviewed_at', 'reviewed_by',
];
protected $casts = [
'privacy_consent' => 'boolean',
'consented_at' => 'datetime',
'metadata' => 'array',
'reviewed_at' => 'datetime',
];
/** @return BelongsTo<Opportunity, $this> */
public function opportunity(): BelongsTo
{
return $this->belongsTo(Opportunity::class);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
/**
* @property string $title
* @property array<string, string> $content
* @property string|null $project_id
* @property string|null $cluster_id
* @property string|null $corridor_id
* @property int|null $editor_id
*/
class InvestmentDossier extends DomainRecord
{
protected $casts = ['content' => 'array', 'version_no' => 'integer'];
}
+6
View File
@@ -0,0 +1,6 @@
<?php
namespace App\Models;
/** @property string $cluster_id */
class InvestmentGap extends DomainRecord {}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class InvestmentItem extends Model
{
use HasUuids;
protected $guarded = ['id'];
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class InvestmentLegalSource extends Model
{
use HasUuids;
protected $guarded = ['id'];
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InvestmentSpecificNeed extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['details' => 'encrypted:array'];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class InvestmentSubmission extends Model
{
use HasUuids;
protected $guarded = ['id'];
protected function casts(): array
{
return ['payload' => 'encrypted:array', 'version' => 'integer'];
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Models;
class InvestmentZone extends DomainRecord
{
protected $casts = ['version_no' => 'integer', 'published_at' => 'datetime'];
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Database\Factories\InvestorFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Investor extends Model
{
/** @use HasFactory<InvestorFactory> */
use HasFactory;
use HasUuids, SoftDeletes;
protected $table = 'investors';
protected $guarded = ['id'];
public function newUniqueId(): string
{
// Keep application-generated identifiers aligned with gen_random_uuid().
return (string) Str::uuid();
}
/** @return array<string, string> */
protected function casts(): array
{
return [
'version_no' => 'integer',
'created_at' => 'immutable_datetime',
'updated_at' => 'immutable_datetime',
'deleted_at' => 'immutable_datetime',
];
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Models;
/**
* @property string $dossier_id
* @property string|null $origin_dossier_id
* @property int|null $interest_request_id
* @property string $status
*/
class InvestorJourney extends DomainRecord {}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class LabourDemand extends DomainRecord {}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property string|null $investor_id
* @property string $status
*/
class Lead extends CrmRecord
{
protected $table = 'leads';
protected $casts = ['estimated_amount' => 'decimal:2'];
/** @return BelongsTo<Investor, $this> */
public function investor(): BelongsTo
{
return $this->belongsTo(Investor::class);
}
/** @return BelongsTo<User, $this> */
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
/** @return HasMany<CrmTask, $this> */
public function tasks(): HasMany
{
return $this->hasMany(CrmTask::class);
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class Occupation extends DomainRecord {}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Opportunity extends Model
{
use SoftDeletes;
protected $fillable = [
'sector_id', 'reference', 'title', 'slug', 'summary', 'description',
'province', 'district', 'promoter_name', 'promoter_type',
'maturity_level', 'maturity_label', 'estimated_investment_amount',
'estimated_investment_currency', 'financing_modality',
'expected_impact', 'status', 'is_featured', 'published_at',
'last_validated_at',
];
protected $casts = [
'is_featured' => 'boolean',
'published_at' => 'datetime',
'last_validated_at' => 'datetime',
'estimated_investment_amount' => 'decimal:2',
];
/** @return BelongsTo<Sector, $this> */
public function sector(): BelongsTo
{
return $this->belongsTo(Sector::class);
}
/** @param Builder<Opportunity> $query
* @return Builder<Opportunity>
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now());
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
/** @property string $id */
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
use HasFactory;
use HasUuids, SoftDeletes;
protected $table = 'projects';
protected $guarded = ['id'];
public function newUniqueId(): string
{
// Keep application-generated identifiers aligned with gen_random_uuid().
return (string) Str::uuid();
}
/** @return array<string, string> */
protected function casts(): array
{
return [
'version_no' => 'integer',
'created_at' => 'immutable_datetime',
'updated_at' => 'immutable_datetime',
'deleted_at' => 'immutable_datetime',
'budget_amount' => 'decimal:2',
'metadata' => 'array',
'start_date' => 'immutable_date',
'end_date' => 'immutable_date',
];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Sector extends Model
{
use SoftDeletes;
protected $fillable = [
'name', 'slug', 'short_description', 'description',
'icon', 'sort_order', 'is_active', 'published_at',
];
protected $casts = [
'is_active' => 'boolean',
'published_at' => 'datetime',
];
/** @return HasMany<Opportunity, $this> */
public function opportunities(): HasMany
{
return $this->hasMany(Opportunity::class);
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class Skill extends DomainRecord {}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Models;
class TrainingCapacity extends DomainRecord
{
protected $table = 'training_capacities';
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Models;
class TrainingProvider extends DomainRecord {}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
/**
* @property int $id
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string $password
* @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property Carbon|null $two_factor_confirmed_at
* @property string|null $remember_token
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements MustVerifyEmail, PasskeyUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
/* @chisel-2fa */
'two_factor_confirmed_at' => 'datetime',
/* @end-chisel-2fa */
];
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
namespace App\Models;
/** @property string $cluster_id */
class ValueChain extends DomainRecord {}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Models;
/**
* @property string $from_value_chain_id
* @property string $to_value_chain_id
*/
class ValueChainLink extends DomainRecord {}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Providers;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureDefaults();
Gate::define('manage-portal', fn (User $user) => $user->hasVerifiedEmail()
&& in_array(strtolower($user->email), config('portal.managers', []), true));
foreach ([Sector::class, Opportunity::class] as $model) {
$invalidate = function () {
Cache::forget('portal_home_pt');
Cache::forget('portal_home_en');
Cache::forget('portal_home_v2_pt');
Cache::forget('portal_home_v2_en');
};
$model::saved($invalidate);
$model::deleted($invalidate);
}
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)->by($request->ip()));
}
/**
* Configure default behaviors for production-ready applications.
*/
protected function configureDefaults(): void
{
Date::use(CarbonImmutable::class);
DB::prohibitDestructiveCommands(
app()->isProduction(),
);
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()
->letters()
->numbers()
->symbols()
->uncompromised()
: null,
);
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Providers;
/* @chisel-registration */
use App\Actions\Fortify\CreateNewUser;
/* @end-chisel-registration */
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
/* @chisel-registration */
Fortify::createUsersUsing(CreateNewUser::class);
/* @end-chisel-registration */
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()),
'status' => $request->session()->get('status'),
]));
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
'passwordRules' => Password::defaults()->toPasswordRulesString(),
]));
Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/forgot-password', [
'status' => $request->session()->get('status'),
]));
/* @chisel-email-verification */
Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/verify-email', [
'status' => $request->session()->get('status'),
]));
/* @end-chisel-email-verification */
/* @chisel-registration */
Fortify::registerView(fn () => Inertia::render('auth/register', [
'passwordRules' => Password::defaults()->toPasswordRulesString(),
]));
/* @end-chisel-registration */
/* @chisel-2fa */
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
/* @end-chisel-2fa */
/* @chisel-password-confirmation */
Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password'));
/* @end-chisel-password-confirmation */
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
/* @chisel-2fa */
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
/* @end-chisel-2fa */
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
/* @chisel-passkeys */
RateLimiter::for('passkeys', function (Request $request) {
return Limit::perMinute(10)->by(
($request->input('credential.id') ?: $request->session()->getId()).'|'.$request->ip(),
);
});
/* @end-chisel-passkeys */
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Services\Ai;
use App\Models\AiRun;
use App\Models\User;
use Illuminate\Support\Facades\DB;
final class AiGateway
{
public function __construct(private EvidenceRepository $evidence, private AssistiveRules $rules) {}
/** @param array<string, mixed> $input */
public function execute(User $actor, array $input): AiRun
{
abort_unless(config('investmoz_ai.enabled') && $actor->can('manage-portal'), 403);
$capability = $input['capability'];
abort_unless(in_array($capability, config('investmoz_ai.capabilities'), true), 422);
$purpose = match ($capability) {
'classify' => 'interest_triage', 'quality' => 'data_quality', 'sla' => 'execution_monitoring', default => 'investment_information',
};
abort_unless(($input['purpose'] ?? null) === $purpose, 422, 'Finalidade incompatível.');
abort_unless(DB::table('ai_controls')->where('capability', $capability)->where('suspended', false)->exists(), 503, 'Capacidade suspensa.');
$start = hrtime(true);
$language = $input['language'] ?? 'pt';
$items = match ($capability) {
'assistant', 'search' => $this->evidence->search($input['query'], $language, $input),
'summary' => $this->evidence->excerpts($input['source_id']),
'recommend' => $this->rules->recommend($input),
'classify' => $this->rules->classify((int) $input['interest_id']),
'quality' => $this->rules->quality(),
'sla' => $this->rules->sla(),
default => abort(422),
};
$mode = match ($capability) {
'assistant', 'search' => 'lexical_evidence', 'summary' => 'extractive_preview', default => 'structured_rules',
};
$result = [
'items' => $items, 'mode' => $mode, 'model' => null, 'language' => $language,
'status' => $items === [] ? 'no_evidence_or_matches' : 'review_required',
'confidence' => 'not_calibrated', 'generated_claims' => [], 'predictions' => [],
'limitations' => ['not_an_official_decision', 'no_return_or_approval_guarantee', 'no_generative_model',
'no_semantic_search', 'human_review_required', 'bounded_results_not_exhaustive'],
'computed_at' => now()->toIso8601String(),
'human_handoff_url' => '/management/ai',
];
$sourceIds = array_values(array_unique(array_filter(array_column($items, 'source_id'))));
return DB::transaction(function () use ($actor, $input, $capability, $mode, $result, $sourceIds, $start): AiRun {
// Recheck suspension and evidence immediately before retaining a result.
abort_unless(DB::table('ai_controls')->where('capability', $capability)->lockForUpdate()->value('suspended') === false, 503);
abort_unless($this->evidence->allStillEligible($sourceIds), 409, 'A autorização de uma fonte mudou.');
$run = AiRun::create([
'actor_id' => $actor->id, 'capability' => $capability, 'purpose' => $input['purpose'], 'mode' => $mode,
'rules_version' => config('investmoz_ai.rules_version'), 'prompt_version' => config('investmoz_ai.prompt_version'),
'input_hash' => hash_hmac('sha256', json_encode($input, JSON_THROW_ON_ERROR), (string) config('app.key')),
'result' => $result, 'source_ids' => $sourceIds,
'latency_ms' => (int) ((hrtime(true) - $start) / 1000000),
]);
DB::table('audit_events')->insert(['source_system' => 'ai-gateway', 'entity_type' => 'ai_runs',
'entity_id' => $run->id, 'actor_user_id' => $actor->id, 'action' => 'assistance_generated',
'after_data' => json_encode(['capability' => $capability, 'mode' => $mode, 'source_ids' => $sourceIds,
'rules_version' => config('investmoz_ai.rules_version'), 'latency_ms' => $run->latency_ms], JSON_THROW_ON_ERROR)]);
return $run;
});
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Services\Ai;
use App\Models\CrmTask;
use App\Models\InterestRequest;
use App\Models\Lead;
use App\Models\Opportunity;
use Illuminate\Support\Facades\DB;
final class AssistiveRules
{
/** @param array<string, mixed> $input
* @return list<array<string, mixed>>
*/
public function recommend(array $input): array
{
$query = Opportunity::published();
$factors = [];
foreach (['sector_id', 'province', 'maturity_level'] as $field) {
if (isset($input[$field]) && $input[$field] !== '') {
$query->where($field, $input[$field]);
$factors[] = $field;
}
}
if ($factors === []) {
return [];
}
return array_values($query->orderBy('id')->limit(10)->get()->map(fn ($opportunity) => [
'record_type' => 'opportunity', 'record_id' => $opportunity->id, 'title' => $opportunity->title,
'source_url' => '/oportunidades/'.$opportunity->slug,
'score' => count($factors), 'score_max' => 3, 'score_meaning' => 'matched_declared_filters',
'favourable_factors' => $factors, 'incompatibilities' => [],
'eliminatory_conditions' => ['not_published', 'mismatch_with_any_selected_filter'],
'missing_data' => array_filter(['budget', 'time_horizon', 'risk_profile',
$opportunity->last_validated_at ? null : 'validation_date']),
'source_date' => $opportunity->last_validated_at?->toIso8601String(),
'semantic_similarity' => null,
])->all());
}
/** @return list<array<string, mixed>> */
public function classify(int $id): array
{
$interest = InterestRequest::findOrFail($id);
$consent = DB::table('consents')->where('subject_ref', $interest->reference)->where('purpose', 'ai_assistance')
->where('status', 'granted')->whereNotNull('granted_at')->where('granted_at', '<=', now())->whereNull('deleted_at')->whereNull('withdrawn_at')->exists();
abort_unless($consent, 422, 'É necessário consentimento específico registado para assistência sobre este pedido.');
// No name, email, phone, message or sensitive attribute is used.
return [[
'record_type' => 'interest', 'record_id' => $interest->id,
'source_url' => '/management/interests/'.$interest->id,
'sector_declared' => $interest->sector_interest,
'investment_range_declared' => $interest->investment_range,
'intention' => $interest->opportunity_id ? 'interest_in_identified_opportunity' : 'general_information',
'priority' => 'human_review_required', 'suggested_owner' => null,
'next_action' => config('investmoz_ai.classification.next_action'),
'missing_data' => ['confirmed_capacity', 'time_horizon', 'responsible_person', 'institutional_priority'],
'source_date' => $interest->created_at?->toIso8601String(),
]];
}
/** @return list<array<string, mixed>> */
public function quality(): array
{
$alerts = [];
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereNull('owner_user_id')->orderBy('id')->limit(20)->get(['id']) as $lead) {
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_owner', 'source_url' => '/management/crm/leads/'.$lead->id];
}
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['new', 'qualified'])->whereDoesntHave('tasks', fn ($q) => $q->whereIn('status', ['open', 'in_progress']))
->orderBy('id')->limit(20)->get(['id']) as $lead) {
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_next_action', 'source_url' => '/management/crm/leads/'.$lead->id];
}
foreach (DB::table('ai_sources')->where('valid_until', '<', now()->toDateString())->orderBy('id')->limit(20)->get(['id', 'valid_until']) as $source) {
$alerts[] = ['record_type' => 'source', 'record_id' => $source->id, 'rule' => 'expired_source', 'valid_until' => $source->valid_until];
}
foreach (DB::table('documents')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
->select('sha256')->selectRaw('count(*) as copies')->groupBy('sha256')->havingRaw('count(*) > 1')->limit(20)->get() as $duplicate) {
$alerts[] = ['record_type' => 'document_group', 'rule' => 'duplicate_document_checksum', 'copies' => (int) $duplicate->copies];
}
foreach (DB::table('geo_geometries')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
->whereRaw('NOT ST_IsValid(geom)')->limit(20)->get(['id']) as $geometry) {
$alerts[] = ['record_type' => 'geometry', 'record_id' => $geometry->id, 'rule' => 'invalid_geometry'];
}
return $alerts;
}
/** @return list<array<string, mixed>> */
public function sla(): array
{
return array_values(CrmTask::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())->orderBy('due_at')->limit(50)
->get()->map(fn ($task) => [
'record_type' => 'task', 'record_id' => $task->id, 'rule' => 'overdue_open_task',
'due_at' => $task->getAttribute('due_at')?->toIso8601String(), 'lead_id' => $task->lead_id,
'source_url' => $task->lead_id ? '/management/crm/leads/'.$task->lead_id : null,
'suggested_action' => 'review_owner_deadline_and_dependencies',
'impact' => 'not_estimated', 'priority' => 'human_review_required',
])->all());
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Services\Ai;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
final class EvidenceRepository
{
public function eligible(): Builder
{
return DB::table('ai_sources as s')->join('documents as d', 'd.id', '=', 's.document_id')
->where('s.status', 'approved')->where('s.purpose', 'investment_information')
->where('s.valid_until', '>=', now()->toDateString())->where('s.source_date', '<=', now()->toDateString())
->where('d.status', 'active')->whereNull('d.deleted_at')->whereIn('d.data_classification', ['public', 'internal'])
->whereColumn('s.document_version', 'd.version_no')->whereColumn('s.document_checksum', 'd.sha256');
}
/** @param array<string, mixed> $filters
* @return list<array<string, mixed>>
*/
public function search(string $query, string $language, array $filters = []): array
{
$configuration = $language === 'pt' ? 'portuguese' : 'english';
$builder = $this->eligible()->join('ai_chunks as c', 'c.source_id', '=', 's.id')
->where('s.language', $language)->whereRaw('c.lexical @@ websearch_to_tsquery(?::regconfig, ?)', [$configuration, $query])
->select(['c.id as chunk_id', 's.id as source_id', 's.document_id', 's.document_version', 's.title', 's.language',
's.nature', 's.source_url', 's.source_date', 's.valid_until', 'c.content', 'c.checksum'])
->selectRaw('ts_rank_cd(c.lexical, websearch_to_tsquery(?::regconfig, ?)) as rank', [$configuration, $query]);
if (! empty($filters['source_id'])) {
$builder->where('s.id', $filters['source_id']);
}
if (! empty($filters['nature'])) {
$builder->where('s.nature', $filters['nature']);
}
return array_values($builder->orderByDesc('rank')->orderBy('c.id')->limit((int) config('investmoz_ai.retrieval_limit', 5))
->get()->map(fn ($row) => (array) $row)->all());
}
/** @return list<array<string, mixed>> */
public function excerpts(string $id): array
{
return array_values($this->eligible()->join('ai_chunks as c', 'c.source_id', '=', 's.id')->where('s.id', $id)
->select(['c.id as chunk_id', 's.id as source_id', 's.document_id', 's.document_version', 's.title', 's.language',
's.nature', 's.source_url', 's.source_date', 's.valid_until', 'c.content', 'c.checksum'])
->orderBy('c.position')->limit(3)->get()->map(fn ($row) => (array) $row)->all());
}
/** @param array<array-key, string> $ids */
public function allStillEligible(array $ids): bool
{
return $ids === [] || $this->eligible()->whereIn('s.id', $ids)->count() === count(array_unique($ids));
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Services\Ai;
final class HybridFusion
{
/**
* Fuse already authorised lexical/vector rankings. Scores are ranking signals, not probabilities.
*
* @param list<string> $lexical
* @param list<string> $vector
* @param list<string> $authorised
* @return list<array{id:string,score:float}>
*/
public function rank(array $lexical, array $vector, array $authorised, int $limit = 10): array
{
$scores = [];
foreach ([$lexical, $vector] as $ranking) {
foreach (array_values(array_unique($ranking)) as $position => $id) {
if (in_array($id, $authorised, true)) {
$scores[$id] = ($scores[$id] ?? 0.0) + 1.0 / (60 + $position + 1);
}
}
}
$items = [];
foreach ($scores as $id => $score) {
$items[] = ['id' => $id, 'score' => $score];
}
usort($items, fn ($a, $b) => ($b['score'] <=> $a['score']) ?: strcmp($a['id'], $b['id']));
return array_slice($items, 0, max(0, min(100, $limit)));
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Services\Ai;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class SourceIngestion
{
/** @param array<string, mixed> $data */
public function ingest(array $data, int $actor): string
{
return DB::transaction(function () use ($data, $actor): string {
$document = DB::table('documents')->where('id', $data['document_id'])->whereNull('deleted_at')->lockForUpdate()->first();
if (! $document || $document->status !== 'active' || $document->media_type !== 'text/plain'
|| ! in_array($document->data_classification, ['public', 'internal'], true)) {
throw ValidationException::withMessages(['document_id' => 'Documento activo de texto autorizado necessário; fontes confidenciais/restritas não são admitidas.']);
}
$root = realpath(storage_path('app/private'));
$path = realpath(Storage::disk('local')->path($document->storage_key));
if (! $root || ! $path || ! str_starts_with(str_replace('\\', '/', $path), str_replace('\\', '/', $root).'/')
|| ! is_file($path) || filesize($path) > (int) config('investmoz_ai.max_text_bytes')) {
throw ValidationException::withMessages(['document_id' => 'Ficheiro privado inexistente ou acima do limite.']);
}
$text = file_get_contents($path);
if ($text === false || ! mb_check_encoding($text, 'UTF-8') || str_contains($text, "\0") || trim($text) === ''
|| ! hash_equals($document->sha256, hash('sha256', $text))) {
throw ValidationException::withMessages(['document_id' => 'Formato, conteúdo ou checksum inválido.']);
}
if (DB::table('ai_sources')->where('document_id', $document->id)->where('document_version', $document->version_no)->exists()) {
throw ValidationException::withMessages(['document_id' => 'Esta versão já foi registada. Reveja a fonte existente.']);
}
$id = (string) Str::uuid();
DB::table('ai_sources')->insert([
...$data, 'id' => $id, 'document_version' => $document->version_no, 'document_checksum' => $document->sha256,
'submitted_by' => $actor, 'status' => 'pending', 'purpose' => 'investment_information', 'created_at' => now(), 'updated_at' => now(),
]);
$size = max(100, min(5000, (int) config('investmoz_ai.chunk_characters', 1800)));
foreach (mb_str_split($text, $size) as $position => $chunk) {
DB::table('ai_chunks')->insert(['id' => (string) Str::uuid(), 'source_id' => $id, 'position' => $position,
'language' => $data['language'], 'content' => $chunk, 'checksum' => hash('sha256', $chunk)]);
}
DB::table('audit_events')->insert(['actor_user_id' => $actor, 'entity_type' => 'ai_sources', 'entity_id' => $id,
'action' => 'submitted', 'source_system' => 'ai-gateway', 'after_data' => json_encode(['document_version' => $document->version_no], JSON_THROW_ON_ERROR)]);
return $id;
});
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Services;
use App\Models\Contact;
use App\Models\CrmTask;
use App\Models\InterestRequest;
use App\Models\Investor;
use App\Models\Lead;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ConvertInterestToLead
{
/** @param array{investor_id?: string|null, legal_name?: string|null, investor_type?: string|null, owner_user_id: int, title: string, next_action: string, due_at: string} $data */
public function convert(InterestRequest $interest, array $data, int $actor): Lead
{
return DB::transaction(function () use ($interest, $data, $actor) {
$interest = InterestRequest::lockForUpdate()->findOrFail($interest->id);
$existing = Lead::withTrashed()->where('interest_request_id', $interest->id)->first();
if ($existing) {
if ($existing->trashed()) {
throw ValidationException::withMessages(['investor_id' => 'Este pedido já tem um lead arquivado. Reveja o registo existente.']);
}
return $existing;
}
if (! $interest->privacy_consent) {
throw ValidationException::withMessages(['investor_id' => 'O pedido não tem consentimento de contacto registado.']);
}
$email = strtolower(trim($interest->email));
$journeyInvestor = DB::table('investor_journeys')->where('interest_request_id', $interest->id)->whereNull('deleted_at')->value('investor_id');
if ($journeyInvestor && ($data['investor_id'] ?? null) !== $journeyInvestor) {
throw ValidationException::withMessages(['investor_id' => 'Seleccione o investidor associado à jornada original para preservar a identidade.']);
}
// Serialise intake by normalised email without merging unrelated identities.
DB::select('select pg_advisory_xact_lock(170925, hashtext(?))', [$email]);
$contacts = Contact::whereRaw('lower(trim(email)) = ?', [$email])->get();
if ($contacts->count() > 1) {
throw ValidationException::withMessages(['investor_id' => 'Existem contactos duplicados com este email. É necessária revisão antes da conversão.']);
}
$contact = $contacts->first();
$linkedInvestors = $contact ? DB::table('investor_contacts')->where('contact_id', $contact->id)->pluck('investor_id') : collect();
if (empty($data['investor_id']) && $linkedInvestors->isNotEmpty()) {
throw ValidationException::withMessages(['investor_id' => 'Este contacto já representa um investidor. Seleccione o registo existente.']);
}
$investor = ! empty($data['investor_id']) ? Investor::findOrFail($data['investor_id']) : new Investor;
if (! $investor->exists) {
if (empty($data['legal_name']) || empty($data['investor_type'])) {
throw ValidationException::withMessages(['legal_name' => 'Identifique o novo investidor e o seu tipo.']);
}
PortalAudit::save($investor, ['legal_name' => $data['legal_name'], 'investor_type' => $data['investor_type'], 'status' => 'prospect'], $actor);
}
if (! $contact) {
$contact = new Contact;
PortalAudit::save($contact, ['full_name' => $interest->full_name, 'email' => $email, 'phone' => $interest->phone], $actor);
}
DB::table('investor_contacts')->insertOrIgnore(['investor_id' => $investor->id, 'contact_id' => $contact->id, 'is_primary' => false]);
$lead = new Lead;
PortalAudit::save($lead, ['investor_id' => $investor->id, 'owner_user_id' => $data['owner_user_id'],
'interest_request_id' => $interest->id, 'title' => $data['title'], 'status' => 'new',
'first_source' => 'portal:'.$interest->reference, 'last_source' => 'portal:'.$interest->reference,
'source_system' => 'portal-intake', 'external_id' => $interest->reference], $actor);
PortalAudit::save(new CrmTask, ['lead_id' => $lead->id, 'assignee_user_id' => $data['owner_user_id'],
'title' => $data['next_action'], 'status' => 'open', 'due_at' => $data['due_at']], $actor);
return $lead;
});
}
}

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