@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -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
@@ -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
|
||||
@@ -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=§or=&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.
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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...',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class ClusterProject extends DomainRecord {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Contact extends CrmRecord {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Course extends DomainRecord {}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/** @property string $id */
|
||||
abstract class CrmRecord extends DomainRecord {}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class DevelopmentCorridor extends DomainRecord {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class EconomicCluster extends DomainRecord {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class EducationInvestmentProposal extends DomainRecord {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/** @property string $cluster_id */
|
||||
class InvestmentGap extends DomainRecord {}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class InvestmentZone extends DomainRecord
|
||||
{
|
||||
protected $casts = ['version_no' => 'integer', 'published_at' => 'datetime'];
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class LabourDemand extends DomainRecord {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Occupation extends DomainRecord {}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Skill extends DomainRecord {}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class TrainingCapacity extends DomainRecord
|
||||
{
|
||||
protected $table = 'training_capacities';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class TrainingProvider extends DomainRecord {}
|
||||
@@ -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 */
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/** @property string $cluster_id */
|
||||
class ValueChain extends DomainRecord {}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* @property string $from_value_chain_id
|
||||
* @property string $to_value_chain_id
|
||||
*/
|
||||
class ValueChainLink extends DomainRecord {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user